---
title: Native UI
url: "https://native-sdk.dev/docs/native-ui"
docs_index: /llms.txt
lastUpdated: 2026-10-05
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

The default Native SDK app is native-rendered: the view is a Native markup file (`.native`), the logic is an app core — TypeScript by default, or Zig — and rendering happens through Native SDK's own engine. Scaffold one with `native init my_app` and run it with `native dev`.

An app is four things:

- `src/app.native` — the entire UI: elements, layout, bindings, and message dispatch.
- `Model` — a plain data structure holding your state.
- `Msg` — a tagged union of everything that can happen.
- `update(model, msg)` — the only place state changes.

The runtime owns installation, presentation, resize, event dispatch, timers, and hot reload. In development, `.native` edits refresh the running view while preserving the model and retained state of widgets whose identity remains stable. Zig views can register embedded fragments in `Options.fragment_watch` through `View.fragment("src/header.native")`. Release builds compile markup at comptime and report invalid views with a line and column. The [compiled TypeScript view preview](/docs/typescript#compiled-typescript-views-preview) requires a rebuild after markup edits.

## A complete view

```html title="src/app.native"
<column gap="12" padding="16">
  <row gap="8" cross="center">
    <text-field text="{draft}" placeholder="New task…" on-input="draft_edit" on-submit="add" grow="1" />
    <button variant="primary" on-press="add">Add task</button>
  </row>
  <scroll grow="1">
    <column gap="2">
      <for each="visible" key="id" as="t">
        <row gap="8" padding="6" cross="center">
          <checkbox checked="{t.done}" on-toggle="toggle:{t.id}" label="Done" />
          <text grow="1">{t.title}</text>
        </row>
      </for>
    </column>
  </scroll>
  <status-bar>{openCount} open</status-bar>
</column>
```

## Elements

<table>
  <thead>
    <tr>
      <th>
        Markup
      </th>

      <th>
        Renders as
      </th>

      <th>
        Notes
      </th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>
        `row`

        ,

        `column`
      </td>

      <td>
        flex containers
      </td>

      <td>
        horizontal / vertical main axis
      </td>
    </tr>

    <tr>
      <td>
        `stack`

        ,

        `panel`

        ,

        `card`
      </td>

      <td>
        overlay containers
      </td>

      <td>
        children layer on top of each other —

        `gap`

        can never space them and is a validation error (put a

        `column`

        /

        `row`

        inside for flow)
      </td>
    </tr>

    <tr>
      <td>
        `scroll`
      </td>

      <td>
        scroll view
      </td>

      <td>
        wrap multiple children in a

        `column`

        inside it
      </td>
    </tr>

    <tr>
      <td>
        `list`

        ,

        `grid`
      </td>

      <td>
        list, grid
      </td>

      <td>
        vertical stack / cell grid
      </td>
    </tr>

    <tr>
      <td>
        `tabs`

        ,

        `toggle-group`

        ,

        `button-group`

        ,

        `radio-group`

        ,

        `breadcrumb`

        ,

        `pagination`
      </td>

      <td>
        row containers
      </td>

      <td>
        children flow horizontally (tab buttons, toggle-buttons, radios, ...)
      </td>
    </tr>

    <tr>
      <td>
        `table`

        \>

        `table-row`

        \>

        `table-cell`
      </td>

      <td>
        table
      </td>

      <td>
        rows only inside a table, cells only inside a row (

        `for`

        /

        `if`

        wrappers are fine); cells are text leaves
      </td>
    </tr>

    <tr>
      <td>
        `dropdown-menu`
      </td>

      <td>
        menu surface
      </td>

      <td>
        vertical; children are

        `menu-item`

        s.

        `anchor="below|above"`

        floats it against its parent's frame: a late z-pass above the whole tree, window-clipped (never cropped by a scroll pane), auto-flipping at the window edges, zero flow space — pair with

        `on-dismiss`

        so Escape/click-outside close model-side
      </td>
    </tr>

    <tr>
      <td>
        `accordion`
      </td>

      <td>
        accordion
      </td>

      <td>
        header via the

        `text`

        attribute; children show while

        `selected`

        , dispatch

        `on-toggle`
      </td>
    </tr>

    <tr>
      <td>
        `alert`

        ,

        `bubble`
      </td>

      <td>
        surfaces
      </td>

      <td>
        `alert`

        title via the

        `text`

        attribute; children stack inside.

        `bubble`

        hugs its message up to 80% of the thread (

        `ghost`

        exempt; explicit

        `width`

        wins) and takes one

        `<reactions>`

        child — the reaction pill straddling its bottom edge, one text run, dock via

        `text-alignment`

        (default

        `end`

        );

        `text=`

        on bubble itself is a teaching error (that channel belongs to the pill)
      </td>
    </tr>

    <tr>
      <td>
        `dialog`

        ,

        `drawer`

        ,

        `sheet`
      </td>

      <td>
        modal surfaces
      </td>

      <td>
        root-relative and unaffected by ancestor scroll or clipping: dialogs center in the viewport, drawers span its width and dock to the bottom, and sheets span its height and dock to the right — title via

        `text`

        , wrap in

        `<if>`

        to show conditionally
      </td>
    </tr>

    <tr>
      <td>
        `resizable`
      </td>

      <td>
        resizable panel
      </td>

      <td>
        engine-managed drag handle;

        `width`

        sets the initial width
      </td>
    </tr>

    <tr>
      <td>
        `split`
      </td>

      <td>
        two-pane splitter
      </td>

      <td>
        exactly two element children (nest splits for more panes); the engine synthesizes the draggable divider between them.

        `value`

        binds the model-owned first-pane fraction,

        `on-resize`

        names an f32 Msg variant dispatched with each applied fraction (echo it back through

        `value`

        ),

        `min-width`

        on the panes bounds the drag,

        `gap`

        sets the divider band thickness; the focused divider takes Left/Right (Shift for bigger steps) and Home/End.

        `resize-duration`

        (milliseconds, split only) animates model-driven value moves — the runtime eases the rendered fraction to the new value one presented frame at a time instead of snapping, and reduced-motion appearances snap automatically;

        `resize-easing`

        (

        `linear`

        /

        `standard`

        /

        `emphasized`

        /

        `spring`

        ) shapes the ramp and needs a nonzero duration beside it
      </td>
    </tr>

    <tr>
      <td>
        `tree`
      </td>

      <td>
        disclosure tree
      </td>

      <td>
        vertical container whose descendant rows with

        `role="treeitem"`

        form one roving keyboard focus set: Up/Down walk visible rows (selection follows focus via

        `on-change`

        when bound, otherwise

        `on-press`

        ), Left collapses or moves to the parent row, Right expands or moves to the first child row, Home/End jump to the edges, Enter/Space activate; nested rows derive hierarchy structurally, while flat

        `<for>`

        rows declare their one-based

        `tree-level`

        ; expandable rows bind

        `expanded`

        and

        `on-toggle`

        , and the model owns both states
      </td>
    </tr>

    <tr>
      <td>
        `text`

        ,

        `badge`

        ,

        `tooltip`
      </td>

      <td>
        text leaves
      </td>

      <td>
        `{}`

        interpolation allowed in content;

        `text`

        takes a line policy via

        `wrap`

        (

        `"true"`

        word-wraps;

        `"false"`

        /unset paint a single line) and an overflow policy via

        `overflow`

        (

        `ellipsis`

        — the default trailing elision — or

        `clip`

        ) and the typography rungs via

        `size="heading"`

        /

        `size="display"`

        (themable token steps above title — section headings, hero stats).

        `tooltip`

        with

        `anchor="above|below"`

        floats against its parent — the

        `stack`

        wrapping trigger + tooltip, the same shape as the anchored

        `dropdown-menu`

        — and the runtime owns its visibility through hover intent: it shows after the pointer rests on the trigger for

        `tooltip-delay`

        milliseconds (default 600;

        `"0"`

        shows instantly), skips the delay during the shared 400ms warm window after a pointer-hovered tooltip hides on leave, and shows immediately when the trigger gains keyboard focus; it hides on pointer leave (the only hide that warms), focus departure, Escape, or a press — the model never hears hover. Without

        `anchor`

        it stays a static leaf that paints whenever the view renders it — see

        <a href="/docs/components/tooltip">Tooltip</a>
      </td>
    </tr>

    <tr>
      <td>
        `text`

        \>

        `span`
      </td>

      <td>
        inline styled runs
      </td>

      <td>
        mixed-style text in one wrapped paragraph: span children style runs with

        `weight="regular|medium|bold"`

        ,

        `mono`

        ,

        `italic`

        ,

        `scale`

        (a positive multiplier on the paragraph's base size — inline headings, hero stats),

        `underline`

        ,

        `foreground`

        (a token name);

        `{bindings}`

        interpolate inside spans; whitespace between runs collapses to a single space (none = the runs abut); spans do not nest, take no events, and the paragraph announces as one text run — see

        <a href="#rich-text-inline-spans-and-markdown">Rich text</a>
      </td>
    </tr>

    <tr>
      <td>
        `button`

        ,

        `toggle-button`

        ,

        `list-item`

        ,

        `menu-item`

        ,

        `toggle`

        ,

        `switch`

        ,

        `select`

        ,

        `avatar`
      </td>

      <td>
        text-bearing controls
      </td>

      <td>
        the label is the element content;

        `button`

        ,

        `toggle-button`

        ,

        `list-item`

        , and

        `menu-item`

        also take

        `icon="..."`

        — a vector icon drawn inline (buttons and toggle-buttons before the label, icon-only when the content is empty; list and menu items as a leading slot), one hit target that follows the element's enabled/disabled tint; tab strips use

        `button`

        children, lowered to segmented controls;

        `select`

        shows its

        `placeholder`

        while empty and dispatches

        `on-press`

        ;

        `avatar`

        renders initials, or a runtime image via

        `image="{binding}"`

        .

        `list-item`

        alone also accepts element children IN PLACE of the text run (the list-row composite): the children flow horizontally inside the row's flat chrome — transparent at rest, full-width hover/selected washes, one hit target — so composite rows (title + snippet, leading icon + trailing badge) remain list rows instead of bordered boxes; name the row with

        `label`

        and never mix a text run with children. Rows that really are cards (a kanban board's draggable cards) opt into the card treatment explicitly with

        `background`

        /

        `radius`
      </td>
    </tr>

    <tr>
      <td>
        `checkbox`

        ,

        `radio`

        ,

        `slider`

        ,

        `progress`
      </td>

      <td>
        value controls
      </td>

      <td>
        `checked`

        ,

        `value`
      </td>
    </tr>

    <tr>
      <td>
        `text-field`

        ,

        `input`

        ,

        `search-field`

        ,

        `combobox`

        ,

        `textarea`
      </td>

      <td>
        text entry
      </td>

      <td>
        `placeholder`

        ; edits arrive via

        `on-input`
      </td>
    </tr>

    <tr>
      <td>
        `status-bar`
      </td>

      <td>
        status bar
      </td>

      <td>
        content only, no children
      </td>
    </tr>

    <tr>
      <td>
        `separator`

        ,

        `spacer`
      </td>

      <td>
        separator, flexible space
      </td>

      <td>
        `separator`

        is axis-aware — a horizontal rule in a

        `column`

        , a thin vertical divider in a

        `row`

        ; give

        `spacer`

        a

        `grow`
      </td>
    </tr>

    <tr>
      <td>
        `skeleton`

        ,

        `spinner`
      </td>

      <td>
        loading leaves
      </td>

      <td>
        size

        `skeleton`

        with

        `width`

        /

        `height`
      </td>
    </tr>

    <tr>
      <td>
        `icon`
      </td>

      <td>
        vector icon leaf
      </td>

      <td>
        `name`

        picks the icon: a bare literal is one of the curated stroke-dialect built-ins (compile-checked; including transport, files/folders, arrows/chevrons, panes (panel-left, panel-right), tooling (terminal, wrench), theming, and the full GitHub vocabulary (git-pull-request, git-merge, git-branch, circle-dot, check-circle, x-circle));

        `app:<name>`

        reaches an icon the app registered at boot (verified by

        `native check`

        against the model contract), and one

        `{binding}`

        defers the choice to model data (an unknown resolved name draws the missing-icon fallback with a Debug warning naming the value - never a silent gap); tint with

        `foreground`

        , size with

        `width`

        /

        `height`

        ; never a press target — decoration inside pressable rows passes clicks through to the row (give the icon its own

        `on-press`

        to make it an icon hotspot)
      </td>
    </tr>

    <tr>
      <td>
        `markdown`
      </td>

      <td>
        rendered markdown
      </td>

      <td>
        leaf;

        `source`

        is one

        `{binding}`

        — see

        <a href="#rich-text-inline-spans-and-markdown">Rich text</a>
      </td>
    </tr>

    <tr>
      <td>
        `media-surface`
      </td>

      <td>
        media surface
      </td>

      <td>
        composites a texture produced OUTSIDE the widget tree (video, camera, an external renderer) like any widget;

        `surface="{binding}"`

        (required) binds the model-owned

        `u64`

        surface id a Zig-tier producer targets — no intrinsic size, display-only,

        `label`

        it; goldens and replay see the deterministic id-derived placeholder, never producer frames — see

        <a href="/docs/components/media-surface">Media Surface</a>

        and

        <a href="/docs/media-producers">Media Producers</a>
      </td>
    </tr>

    <tr>
      <td>
        `video`
      </td>

      <td>
        video playback
      </td>

      <td>
        leaf;

        `src`

        declares the app's single platform-decoded playback (an assets path or

        `http(s)`

        URL, local-first like audio),

        `autoplay`

        /

        `loop`

        /

        `muted`

        shape it, and

        `controls`

        composes the house transport chrome — without it the element is the surface alone and your update code drives the video command vocabulary; no intrinsic size,

        `label`

        it; goldens and replay see the placeholder, transport is journaled truth — see

        <a href="/docs/components/video">Video</a>
      </td>
    </tr>

    <tr>
      <td>
        `stepper`

        \>

        `step`
      </td>

      <td>
        stage stepper
      </td>

      <td>
        `active="{index}"`

        derives each step's completed/active/pending state; steps are text leaves joined by connectors — see

        <a href="#pipeline-components-stepper-timeline-and-nav">Pipeline components</a>
      </td>
    </tr>

    <tr>
      <td>
        `timeline`

        \>

        `timeline-item`
      </td>

      <td>
        ledger/timeline list
      </td>

      <td>
        items are leaves whose

        `title`

        /

        `description`

        /

        `meta`

        /

        `indicator`

        attributes carry the content;

        `on-press`

        makes the whole item pressable
      </td>
    </tr>

    <tr>
      <td>
        `segmented-control`
      </td>

      <td>
        exclusive-choice control
      </td>

      <td>
        standalone text-bearing segment; bind

        `selected`

        , dispatch

        `on-press`

        , and optionally add a vector

        `icon`

        ; compose several in a row, or use

        <a href="/docs/components/tabs">tabs</a>

        for the strip-and-panel pattern
      </td>
    </tr>

    <tr>
      <td>
        `chart`

        \>

        `series`
      </td>

      <td>
        data chart
      </td>

      <td>
        each series binds a model

        `[]const f32`

        iterable via

        `values="{binding}"`

        (

        `kind`

        is

        `line`

        /

        `area`

        /

        `bar`

        ,

        `color`

        a token name);

        `y-min`

        /

        `y-max`

        /

        `grid-lines`

        /

        `baseline`

        shape the plot,

        `x-labels`

        /

        `y-labels`

        add muted axis ticks,

        `hover-details`

        adds the pointer detail card — see

        <a href="#charts">Charts</a>
      </td>
    </tr>

    <tr>
      <td>
        `input-group`

        \>

        `textarea`

        \+

        `input-group-actions`
      </td>

      <td>
        grouped input
      </td>

      <td>
        the composer shape: ONE bordered field wrapping exactly one

        `textarea`

        (first) plus an optional

        `input-group-actions`

        row of controls inside the same border — the group wears the focus ring for its focused descendant and the textarea's own chrome dissolves; put a

        `<spacer grow="1"/>`

        between leading and trailing controls — see

        <a href="/docs/components/input-group">Input Group</a>
      </td>
    </tr>
  </tbody>
</table>

A tab strip, a select trigger, and a table read the way you would guess:

```html
<tabs gap="4">
  <button selected="{tab == overview_tab}" on-press="set_tab:{overview_tab}">Overview</button>
  <button selected="{tab == data_tab}" on-press="set_tab:{data_tab}">Data</button>
</tabs>
<select placeholder="Pick a fruit" on-press="open_picker">{choice}</select>
<!-- the options: an if-gated <dropdown-menu anchor="below" on-dismiss="close_picker"> beside the select in a stack -->
<table gap="2">
  <for each="rows" key="id" as="r">
    <table-row gap="4">
      <table-cell on-press="pick_row:{r.id}">{r.name}</table-cell>
      <table-cell>{r.qty}</table-cell>
    </table-row>
  </for>
</table>
```

`data_grid`, `popover`, and `menu_surface` are not available as markup elements. Use the supported layout, menu, and table elements to compose the view. Vector icon buttons use `<button size="icon" icon="..." label="..." on-press="..." />`. Runtime image bindings are available on `avatar` and `image`; per-state styles beyond design tokens use the Zig builder.

Apps with their own iconography can parse any stroke-dialect/Feather/Tabler-dialect SVG at comptime (`canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg"))`) and register it at boot with `canvas.icons.registerAppIcons(&table)`: the draw paths (icon leaves via `ui.appIcon`, `ElementOptions.icon` on buttons, toggle buttons, icon buttons, and list/menu items) resolve registered names exactly like built-ins. Markup reaches them through the `app:` NAMESPACE (`<icon name="app:waveform"/>`, `icon="app:waveform"`): bare names keep the closed built-in vocabulary (the compiled engine proves them at comptime, where a runtime registration cannot exist), while `app:` names are structurally accepted by both engines and verified by `native check` against the model contract - declare the table as `pub const app_icons` on the app root so the contract emit reflects the same names `main` registers. Bound icon names (`icon="{binding}"`) make the choice model data - a per-row status icon, a play/pause toggle - and any name that fails to resolve at draw time renders the missing-icon fallback (a slashed circle) with a Debug warning naming the value, never a silent gap.

**Layout.** `gap`, `padding`, `grow`, `width`, `height`, `min-width`, `max-width`, `main`, `cross`, and `columns` control sizing and flow. `gap` requires a flow container; put a row or column inside an overlay surface. On `split`, it sets divider thickness. `width` and `height` set definite sizes (`resizable` treats width as its initial size); `min-width` is a floor and `max-width` a ceiling. `columns` applies only to grids. Debug builds report `zero_canvas_layout` when child minimum sizes exceed a container.

**Text.** `wrap="true"` wraps and reserves the measured height. Single-line text defaults to `overflow="ellipsis"`; `clip` cuts at the frame. `text-alignment="start|center|end"` aligns leaves, status bars, and surface titles. `size` selects `default`, `sm`, `lg`, or `icon`; text also accepts `heading` and `display`. Change typography tokens for other sizes.

**Floating surfaces.** `anchor="below|above"` positions a dropdown menu or tooltip against its parent and flips it at window edges. `anchor-alignment="start|end|stretch"` and `anchor-offset` (4 points by default) adjust placement. Anchored tooltips have runtime-owned visibility and `tooltip-delay` (600 ms by default); keyboard focus shows them immediately. Dropdown visibility belongs in the model.

**Control state.** `variant`, `disabled`, `checked`, `selected`, and tree-row `expanded` set presentation and state. `autofocus` moves focus on mount or when its bound value changes to true.

**Identity and semantics.** `key` preserves sibling identity; `global-key` preserves identity across containers. `label` replaces the accessible name derived from visible text. `role="treeitem"` joins a tree's roving focus group; flat sibling rows declare their one-based depth through `tree-level`.

**Window chrome.** `window-drag="true"` creates a [drag region](#hidden-titlebar-drag-regions-and-chrome-insets).

## Styling with design tokens

Color and radius attributes reference design tokens by name — literals only, validated against the token lists (a typo is a check/compile error, never a silent default):

```html
<row background="surface" radius="md" padding="8">
  <text foreground="text_muted">Muted caption</text>
</row>
```

The color attributes are `background`, `foreground`, `accent`, `accent-foreground`, `border-color`, and `focus-ring`; values are `ColorTokens` field names: `background`, `surface`, `surface_subtle`, `surface_pressed`, `text`, `text_muted`, `syntax_plain`, `syntax_comment`, `syntax_keyword`, `syntax_literal`, `syntax_function`, `syntax_property`, `syntax_constant`, `border`, `accent`, `accent_text`, `destructive`, `destructive_text`, `success`, `success_text`, `warning`, `warning_text`, `info`, `info_text`, `focus_ring`, `shadow`, `scrim`, `disabled`. The `syntax_*` roles are the Geist Code Block palette shared by both built-in packs; `ui.code` and Markdown fences use them automatically. `info` is the violet identity hue beside the status trio — merged PR badges, "new" chips, informational callouts. `radius` takes a `RadiusTokens` name: `sm`, `md`, `lg`, `xl`, `none`.

References resolve against the app's live tokens on every rebuild, so themed apps re-resolve them when the theme changes — a scheme change updates `surface`. Anything dynamic beyond that (raw colors, per-state styling) stays in Zig via `ElementOptions.style`, which always wins over a token reference.

By default the stock tokens follow the system appearance: an app that sets neither `tokens` nor `tokens_fn` derives its theme from the OS light/dark setting (plus high-contrast and reduce-motion) and re-themes live when the user flips it — no restart, no wiring. Apps that own their look opt out by passing explicit `tokens` (a fixed theme) or `tokens_fn` (model-owned theming — the pattern for a custom palette that still follows the system scheme through `on_appearance`).

## Structure tags

`<for each="..." as="...">` repeats its body per item and `<if test="{...}">` renders its children conditionally. A `for` body takes one or more children — elements, `<use>`, `<if>`/`<else>` arms, or nested `<for>`s — so polymorphic rows need no wrapper node. `key` names an item field: every node an item emits shares the item's identity across reorders (same-kind siblings within one item are disambiguated automatically), and a node's own `key`/`global-key` still wins. An `<else>` directly after an `</if>` renders when the test is false; directly after a `</for>` it renders the empty state when the iterable has no items:

```html
<for each="visible" key="id" as="t">
  <if test="{t.done}"> <badge>done</badge> </if>
  <else> <text>{t.title}</text> </else>
</for>
<else> <text>Nothing yet</text> </else>
```

There is no `else-if` chain tag — nest an `<if>`/`<else>` inside the `<else>` body instead.

## Templates

When the same subtree repeats with different data, define it once — templates go at the top of the file, before the single view root:

```html
<template name="board-column" args="title cards">
  <column grow="1" gap="8" label="{title}">
    <text foreground="text_muted">{title}</text>
    <for each="cards" key="id" as="c">
      <row global-key="{c.id}"><text>{c.title}</text></row>
    </for>
  </column>
</template>
<row grow="1" gap="12">
  <use template="board-column" title="Todo"  cards="{todoCards}" />
  <use template="board-column" title="Doing" cards="{doingCards}" />
  <use template="board-column" title="Done"  cards="{doneCards}" />
</row>
```

A `<use>` is allowed anywhere an element is, and its attributes must match the template's declared `args` exactly. Args bind like `for` variables: a `{binding}` naming an iterable (the same set `for each` accepts) is iterable inside the template; anything else — a literal or a scalar binding — is a value for bindings and interpolation. The body is built in place of the `<use>`, so widget ids are identical to writing it out by hand: converting copy-pasted markup to a template changes no ids. Templates may only reference templates defined earlier in the file, which keeps expansion finite; bindings stay zero-argument, so per-case queries (`todoCards`, `doingCards`) remain named model functions.

An arg can declare a literal default with `name=value`, and a use site may then omit it — `args="title trend=flat"` makes `trend` optional. Defaults are literals only: they evaluate in no scope, so a `{binding}` default is rejected with a teaching error, and a missing arg without a default keeps its error too. `args="name="` declares an empty-string default, and defaults are unquoted — quotes in a default would be literal characters, so a quoted default (`name='x'`) is rejected with a teaching error.

## Components

Templates become shared components through `<import>`, which goes at the very top of a file, before its own templates:

```html
<import src="components/board-column.native"/>
<column gap="12">
  <use template="board-column" title="Todo" cards="{todoCards}" />
</column>
```

Paths resolve relative to the importing file, subdirectories and transitive imports included, and everything must stay under the markup root (the root view file's directory) — absolute paths and escapes are rejected. An imported file defines templates only: it is a component file, valid on its own for `native markup check`, and a view root inside one is an error. Importing splices the file's templates (and transitively its imports') before yours, in import order — exactly as if pasted at the import site — so the define-before-use rule is the only ordering rule to learn. Cycles are reported with the cycle path spelled out, and a template name defined twice is an error naming both definition sites: imports never shadow silently.

In a default TypeScript app, `src/app.native` may import any component file under `src/`. The generated build discovers and embeds every other `.native` file for the runtime and compiled engines, including mobile builds. `native dev` re-resolves the imported closure from disk, so editing a component file refreshes the view without restarting. Secondary-window roots keep their narrower `src/windows/` import root.

A template can also take markup children. The body marks the insertion point with a single `<slot/>`, and the use site's children build in the consumer's scope — they see the model paths and loop variables where the `<use>` is written — then land at the slot's position. A `<use>` may also carry `on-press="tag"` or `on-press="tag:{binding}"` to forward a typed press to the expanded root:

```html
<template name="section" args="title">
  <column gap="4">
    <text foreground="text_muted">{title}</text>
    <slot/>
  </column>
</template>
<use template="section" title="Cards">
  <for each="cards" as="c"> <text>{c.title}</text> </for>
</use>
```

A use with no children renders the slot empty; children passed to a template without a slot are an error; a template body takes at most one `<slot/>` (named slots are not supported yet). Structural widget ids hash exactly as if the slot content were written inline, in both engines.

## Expressions

Attribute values take a literal or exactly one `{expression}`; text content interpolates any number of them (`{openCount} open`). An expression is a binding path (`{c.title}`) or a pure, total combination of paths, literals (numbers, `'strings'` in single quotes, `true`/`false`), operators, and the built-in function library — spreadsheet power, never a programming language: no user-defined functions, no effects, no computed message names, and every expression terminates by construction.

Operators: arithmetic `+ - * /` (division always produces a float — wrap in `round()`/`floor()`/`ceil()` for whole-number attributes; dividing by zero is a loud error, never a silent zero or NaN), comparison `== != < <= > >=` (ordering takes numbers only; equality compares any two values, and different types are simply not equal), boolean `and`/`or`/`not` (booleans only — write `count > 0`, not `count`), and `++` (joins anything as display text, formatted exactly like interpolation). Comparisons do not chain, and `and`/`or` evaluate both sides (expressions are pure, so only errors are observable).

Type discipline is teaching errors over silent coercion: a string minus a number is an error at check/compile time when the types are known, and a loud build failure when they are not — never a NaN. Both engines evaluate through one shared evaluator, so results (float formatting included) are identical bit for bit.

The function library is closed — seventeen functions, and growing the set is a toolkit change: `fixed(x, digits)` (exact decimals), `thousands(n)` (`1,234,567`), `percent(fraction, digits?)` (`0.42` → `42%`), `date(ts)`/`time(ts)`/`datetime(ts)` (a model unix timestamp in seconds, formatted in UTC — formatting model time is pure; *reading the clock* is an effect, so `now()` is a teaching error pointing at the model/fx loop), `upper`/`lower`/`trim` (ASCII case mapping; other characters pass through), `min`/`max`/`abs`, `round`/`floor`/`ceil` (number → whole number), `plural(count, singular, plural)` (`{plural(n, 'item', 'items')}`), and `pad(x, width)` (zero-pads the integer value of x to `width` digits — `pad(7, 2)` → `07`, a negative sign precedes the zeros without counting toward the width, and numbers wider than `width` print in full; the mm:ss counter function: `{pad(minutes, 2)}:{pad(seconds, 2)}`).

Complexity is bounded and taught one past the bound: at most 256 bytes, 64 terms, and 16 nesting levels per expression — anything larger is a named core helper by design.

The same line separates inline arithmetic from core helpers. Inline expression arithmetic is sanctioned for one-off presentation-level derivation — `{percent(done / total)}` on the single readout that shows it is exactly what expressions are for. The moment a derivation is reused in a second binding, deserves a name, or carries meaning the model owns (a threshold, a rule, a policy), it belongs in a named helper: `{completionRate}` reads at the binding site, tests in the core's language, and changes in one place.

In the default TypeScript core, model fields bind by their authored names (`nextId` is `{nextId}`), and an exported helper declared in `src/core.ts` with exactly one `Model` parameter becomes a derived binding under its exported name. Scalar-returning helpers drive text/attributes; array-returning helpers drive `for each` and chart series; item record fields continue the path. See [TypeScript Cores](/docs/typescript) for the supported return shapes and module boundary.

In a Zig core, bindings resolve struct fields and zero-argument public model methods. A `for each` iterable may also be a slice, public array declaration, `pub fn (*const Model) []const T`, or `pub fn (*const Model, std.mem.Allocator) []const T`; the allocator form derives filtered rows into the one-build arena. Scalar bindings take that form too: `{summary}` binds `pub fn summary(m: *const Model, arena: std.mem.Allocator) []const u8`. Arena-computed values work in text, attributes, message payloads, and function arguments, but not as comparison operands — compare source fields or bind a boolean helper. In either tier, write `<if test>` predicates explicitly (`test="{count > 0}"` or `test="{hasItems}"`) instead of leaning on numeric truthiness. Expressions are allowed in text interpolation, attribute values, `if` tests, and template args at use sites; message tags and payloads, `for each` iterables, and import paths stay path-only.

## Messages

`on-press`, `on-toggle`, `on-change`, and `on-submit` name a variant of your `Msg` union, optionally with one binding payload (`toggle:{t.id}`). `on-input` names a variant carrying the runtime's text-input event, and each core tier declares that in its own vocabulary. In a TypeScript core the event union mirrors the runtime's event vocabulary structurally, and `on-input` matches the shape and translates each runtime event into your union at dispatch — import it from `@native-sdk/core/text` (or `@native-sdk/core/events`), or declare the same shape in-file; your `update` reduces the events over the draft bytes. In a Zig core the payload is `canvas.TextInputEvent`; pair it with `canvas.TextBuffer` in the model for editor state that survives rebuilds — the markup binds a `pub fn` returning the text (`text="{draft}"`), and binding the `TextBuffer` field itself directly is a teaching error pointing at this pattern:

```ts
import { type TextInputEvent } from "@native-sdk/core/text";
// insert_text / delete_* / clear / move_caret / set_selection /
// set_composition / commit_composition / cancel_composition — the full
// union is the SDK's; the same shape declared in-file matches identically.

export type Msg =
  | { readonly kind: "add" }
  | { readonly kind: "draft_edit"; readonly edit: TextInputEvent };
```

```zig
draft_buffer: canvas.TextBuffer(64) = .{},
pub fn draft(model: *const Model) []const u8 {
    return model.draft_buffer.text();
}
// in update:
.draft_edit => |edit| model.draft_buffer.apply(edit),
.add => { model.addTask(model.draft_buffer.text()); model.draft_buffer.clear(); },
```

`on-scroll` on a `scroll` element names a Msg carrying `ScrollState` from `@native-sdk/core/events`. Its eight numeric fields are `offsetX`, `offsetY`, `velocityX`, `velocityY`, `viewportExtentX`, `viewportExtentY`, `contentExtentX`, and `contentExtentY`. Zig uses `canvas.ScrollState` with snake\_case field names and `Ui.scrollMsg(.tag)`. The event reports the state already applied by wheel, momentum, keyboard, or accessibility scrolling. Echo `offsetY` through `value` and `offsetX` through `value-x` for a controlled region.

`on-resize` (on the `split` element; `Ui.valueMsg(.tag)` on `on_resize` in Zig views) names a variant whose payload is the new first-pane fraction (`f32`): after every divider drag, keyboard adjustment, or assistive increment/decrement the runtime delivers the fraction it already applied and clamped — store it in the model and echo it back through the split's `value`, and rebuilds never fight live resizing.

`on-drag` makes any element a draggable spatial object and names the closed record `{ sourceId, phase, x, y, viewWidth, viewHeight }`. Markup supplies numeric `sourceId` from a binding such as `on-drag="card_dragged:{card.id}"`; the runtime supplies `phase` (a number able to represent 0, 1, and 2) plus floating-point view-local `x`, `y`, `viewWidth`, and `viewHeight` (`f32`/`f64` in Zig, `number` in TypeScript). Geometry stays floating-point because pointer capture can carry negative coordinates outside the view. Phase 0 means motion, 1 release, and 2 cancellation. The renderer lifts the actual source appearance under the pointer at full opacity and leaves its in-flow space blank. Apps that need precise insertion can keep committed data unchanged during phase 0 while returning a derived view that moves the same `global-key` into the candidate position. Its hidden in-flow rendering is the one card-sized reserved slot: it begins at the source, moves to each candidate, and never duplicates. Keyed draggable neighbors ease between candidate poses; release carries the floating item from its pointer position into that slot and commits the exact order. A plain Escape during the drag dispatches phase 2, consumes that key, and carries the item back to its source slot; pointer cancellation uses the same path. Reduced-motion appearances snap these reflows. `examples/kanban` demonstrates within-column and cross-column reordering with this pattern.

`on-dismiss` (on the dismissible surfaces: `dialog`, `drawer`, `sheet`, `dropdown-menu`; `ElementOptions.on_dismiss` in Zig views) dispatches when Escape or a click outside dismisses the surface, so the model owns the close — clear the open flag in `update`. The engine hides the surface immediately as an optimistic echo; the next rebuild's source tree is truth. Escape works regardless of focus: it dismisses the nearest surface up the focused widget's chain, and when nothing relevant is focused — a menu opened from a plain-text trigger takes no focus — it falls back to the topmost mounted anchored surface. `on-hold` (any element; `ElementOptions.on_hold`) is press-and-hold: a pointer held \~350 ms dispatches the hold Msg and the release presses nothing, a quick click dispatches `on-press` as usual, and a right/ctrl-click with no context menu on its route dispatches the hold Msg immediately (a declared `<context-menu>` always wins the right-click) — the crumb-switcher shape (`on-press` selects, `on-hold` opens an anchored menu). Both legs are live-drivable through automation: `native automate widget-hold <view> <id>` runs the pointer+timer gesture, `widget-context-press <view> <id>` the secondary click.

`on-hover-enter` and `on-hover-leave` dispatch containment changes from mouse or trackpad motion. Binding them makes an element hover-hittable, without adding press behavior, a control hover wash, or an accessibility action. Nested enters run outermost first; leaves run innermost first. Scrolling, reflow, unmounting, and dismissal can cause leaves under a stationary pointer. Overlay occlusion follows hit testing. Touch does not synthesize hover, so provide another way to reach actions or content shown on hover.

`on-double-press` (`ElementOptions.on_double_press` in Zig) handles double clicks. Primary presses within 500 ms and 4 points increase the click count, capped at three. The first release dispatches `on-press`; the second and third dispatch `on-double-press` instead. Without a double handler, each click uses `on-press`. For a list row, pair select-on-press with open-on-double-press, and bind the same open action to `on-submit` for keyboard users.

On a ring-focused `list-item`, Enter dispatches `on-submit` when bound; Space dispatches the select action. Without a submit handler, both keys activate selection. Pointer-focused plain rows follow the [quiet-focus rules](#keyboard-routing-focus-registers-quiet-list-rows-and-the-app-level-fallback).

Presses follow one rule: a click lands on the nearest press-claiming widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest interactive ancestor, and dragging still selects text. Any element with `on-press`, `on-double-press`, `on-toggle`, `on-hold`, or `on-drag` becomes a hit target and press claimer, so a row can bind `on-press` for clicks or `on-drag` for spatial dragging and child text still resolves to that row without overlays or duplicated handlers. Nested claimers resolve to the deepest one; editable text fields, scroll containers, and modal surfaces always claim their own presses. Value/text handlers (`on-change`, `on-submit`, `on-input`) still belong on controls only — the validator, both engines, and the LSP reject them on layout/decoration elements with a teaching error.

## Keyboard routing: focus registers, quiet list rows, and the app-level fallback

Keyboard focus has two registers, and the split is what makes desktop keyboard conventions expressible without per-widget wiring:

- **Ring focus** is the keyboard contract: Tab (and Shift+Tab) walk the focusable widgets and draw the visible focus ring. A ring-focused widget owns its keys in full — Enter/Space activate it, arrows walk its group (list rows, menu items, radios, segments) or step its value (sliders, split dividers), Home/End jump to the group edges.
- **Quiet focus** is the bookkeeping register: a pointer press (or programmatic focus — `autofocus`, the automation `focus` action) records which widget the user last touched, with no ring drawn. It exists so activation can find "the thing the user last touched", not to promise a keyboard cursor. Editable text kinds are the exception and show their ring and caret however focus arrives — a caret is a visible promise.

A key event then resolves top to bottom — the focused widget always outranks the app:

1. **Runtime focus motion.** Tab moves ring focus; group arrows and Home/End walk the focused widget's focus group (menu items, radios, segments under either register — list rows only under the ring, the quiet-list-row rule below), and where selection follows focus (tree rows), the landing row's select dispatches. These keys are consumed by the move.
2. **The focused widget's bound handler.** Activation keys resolve to the widget's `on-press`/`on-toggle` handler, Enter to a bound submit handler on text entry and list rows, arrow steps to a slider's `on-change` — Space on a ring-focused track row selects THAT row.
3. **Structural consume.** A focused widget that answers the key structurally consumes it silently even with nothing bound: any key on an editable text widget (typing must stay typing — checked by widget kind, so a search field blocks app shortcuts without knowing they exist), and any key the widget's kind maps as a control intent.
4. **The app-level fallback.** Only an unclaimed key-down reaches `Options.on_key` (a target-less event — nothing focused — skips straight here). The fallback is a plain function from the key event to an optional Msg:

```zig
// options: .on_key = onKey,
pub fn onKey(keyboard: canvas.WidgetKeyboardEvent) ?Msg {
    if (keyboard.modifiers.hasNavigationModifier() or keyboard.modifiers.shift) return null;
    if (std.ascii.eqlIgnoreCase(keyboard.key, "space")) return .toggle_play;
    if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowdown")) return .select_next;
    if (std.ascii.eqlIgnoreCase(keyboard.key, "arrowup")) return .select_previous;
    if (std.ascii.eqlIgnoreCase(keyboard.key, "enter")) return .play_selected;
    return null;
}
```

One rule bridges the registers: **quiet focus on a plain list row routes no keys.** A quietly focused `list-item` (what clicking a row leaves behind) is transparent to the keyboard — arrows neither walk the row group nor escalate to the ring, Enter and Space fire nothing on the row, and the key reaches the app target-less, exactly as if nothing were focused. This supports selection-follows-intent apps: in a music library the selection is MODEL state — a click selects a row, and the arrows should keep moving that app-owned selection through the fallback (`select_next`/`select_previous` above), not silently start walking engine focus from whichever row was clicked last; without the rule, the first click anywhere in a list re-aimed every arrow and swallowed the spacebar. Tab restores the full contract — a ring-focused row walks on arrows, selects on Space, submits on Enter — and rows carrying `role="treeitem"` are exempt entirely: a [tree](/docs/components/tree)'s roving keymap is the feature, under either register. Every other widget kind keeps its keys under quiet focus too (a quietly focused button still presses on Space); the rule is scoped to plain list rows because only there quiet focus is bookkeeping rather than a keyboard cursor.

`examples/soundboard` is the worked example, pinned end-to-end in its tests: Space toggles the transport from anywhere — after clicking a track row (quiet, transparent), from a focused slider or scroll region (space is not one of their keys, so it falls through) — while a tabbed-to row takes Space for select and Enter for play, and the search field takes every key as typing. Automation note: `native automate widget-action <view> <id> press` escalates a plain list row to the ring register before dispatching its synthesized activation key, so a scripted activation reaches the row the way Tab-then-Enter would.

## Trackpad pinch

Pinch gestures use the view-global `on_pinch` channel, outside widget hit testing. Events have phases `begin`, `change`, and `end`; host cancellation maps to `end`. Each change carries an incremental `scale` delta: apply `zoom *= 1 + scale`, rather than summing deltas. Coordinates `x`/`y` are view-local pointer positions. `window_id` and view `label` identify the source; TypeScript uses `windowId` and `label`.

```zig
// options: .on_pinch = onPinch,
pub fn onPinch(pinch: platform.PinchEvent) ?Msg {
    return switch (pinch.phase) {
        .change => .{ .zoom = .{ .factor = 1 + pinch.scale, .cx = pinch.x, .cy = pinch.y } },
        .begin, .end => null,
    };
}
```

TypeScript cores export the same channel as `pinchMsg` (the `keyMsg` shape), with the record types importable from `@native-sdk/core/events`:

```ts
import { type PinchEvent } from "@native-sdk/core/events";

export function pinchMsg(pinch: PinchEvent): Msg | null {
  if (pinch.phase !== "change") return null;
  return { kind: "zoomed", factor: 1 + pinch.scale, cx: pinch.x, cy: pinch.y };
}
```

macOS emits pinch from trackpad magnification events. Windows and Linux do not currently emit the channel. Recorded input replays the same events. Tests can use `native automate widget-pinch <view-label> <scale> [x y]`, where scale is the final multiplicative zoom and the default anchor is the view center.

## File drops

Native file drops reach a Zig `UiApp` through `Options.on_drop`, after any targeted canvas-widget drop handler. The event carries the source `window_id`, `view_label`, an optional view-local `point`, and every dropped path. TypeScript cores export the same channel as `dropMsg`; import its structural record from `@native-sdk/core/events`. Paths are `Uint8Array` byte text so non-ASCII filesystem names cross without a JS string conversion:

```ts
import { type FileDropEvent } from "@native-sdk/core/events";

export function dropMsg(drop: FileDropEvent): Msg | null {
  if (drop.paths.length === 0) return null;
  return { kind: "file_opened", path: drop.paths[0] };
}
```

The platform event is journaled before either route, so record/replay delivers the identical source, point, and path bytes. The macOS system host reports labeled, view-local points for canvas and WebView drops; an unlabeled window region keeps an empty `viewLabel` and reports a window-content point. A host that cannot resolve either leaves `viewLabel` empty and `point` null.

## Native scrolling and context menus

On macOS, every non-virtualized `scroll` region is driven by an invisible `NSScrollView`: momentum and the system overlay scrollbar are OS-computed while the engine renders the content. This needs no app code — scroll offsets stay on the widget (`sync`, snapshot offsets, and programmatic scrolls work unchanged), and the engine's drawn scrollbar stands down for natively driven regions. Other platforms keep the engine's wheel physics.

Scroll regions pin at their content edges by default on every path — no rubber-band bounce; kinetic motion stops cleanly at the boundary. Bouncing is a per-region opt-in: `overscroll="rubber_band"` in markup or `ElementOptions.overscroll = .rubber_band` in Zig views (the native macOS scroller gets elastic edges, the engine physics overscroll under resistance and spring back). The `ScrollPhysics.overscroll` design token flips the app-wide default; per-region values override it, and `overscroll="none"` pins a region regardless of the token.

Right/ctrl-click presents a real OS context menu at the pointer: `NSMenu` on macOS, `TrackPopupMenu` on Windows, `GtkPopoverMenu` on Linux. Authors declare ONE menu and the platform decides presentation: hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today) mount the same declared items as an anchored canvas surface at the click point automatically — never two authored menus, never a canvas imitation where the OS menu exists. In markup, the menu is a `<context-menu>` child of the interactive element it answers — a hit-target kind or an element made interactive by `on-press`, `on-double-press`, `on-toggle`, `on-hold`, or `on-drag` — holding `menu-item`s (`on-press` required, `disabled` optional) and `separator`s, with `if`/`else`/`for` to swap or repeat items:

```html
<list-item on-press="open:{entry.id}" label="{entry.title}">
  <text grow="1">{entry.title}</text>
  <context-menu>
    <menu-item on-press="open:{entry.id}">Open</menu-item>
    <separator />
    <menu-item on-press="delete:{entry.id}">Delete</menu-item>
  </context-menu>
</list-item>
```

The Zig builder's mirror is `ElementOptions.context_menu`:

```zig
ui.listItem(.{
    .on_press = Msg{ .select = entry.index },
    .context_menu = &.{
        .{ .label = "Open", .msg = Msg{ .select = entry.index } },
        .{ .separator = true },
        .{ .label = "Delete", .msg = Msg{ .delete = entry.index } },
    },
}, entry.title)
```

Editable text fields present the standard Cut / Copy / Paste / Select All menu with no declaration, wired to the same clipboard actions as the keyboard shortcuts; a selected static text presents Copy. Tests drive the native path without pixels: snapshots list each widget's declared items (`context_menu=["Open","Delete"]`), and `native automate widget-context-menu <view> <id> <item-index>` invokes an item through the same dispatch a real pick takes.

## Effects

For anything asynchronous — running `gh`, streaming an agent's stdout — `update` never performs the work; it describes it, and results come back as ordinary Msgs. In a TypeScript core the description is `Cmd` data returned beside the next model ([TypeScript Cores: Effects](/docs/typescript#effects-are-cmd-data) has the whole vocabulary); in a Zig core, `update` takes a third parameter, the effects channel — declare `.update_fx` instead of `.update` (two-argument apps are unchanged). The subprocess stream in both:

```ts
case "start":
  return [model, Cmd.spawn(
    [asciiBytes("gh"), asciiBytes("issue"), asciiBytes("list")],
    {
      key: "issues",
      line: "line",   // each stdout line dispatches { kind: "line", text: Uint8Array }
      exit: "exited", // a clean exit dispatches { kind: "exited", code: number }
      err: "stream_failed",
    },
  )];
case "cancel":
  return [model, Cmd.cancel("issues")]; // the stream's err arm gets `cancelled`
case "line":
  return { ...model, lines: [...model.lines, { text: msg.text }] };
```

```zig
const Effects = native_sdk.UiApp(Model, Msg).Effects;

pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
    switch (msg) {
        .start => fx.spawn(.{
            .key = stream_key,                    // caller-chosen u64, kept in the model
            .argv = &.{ "gh", "issue", "list" },
            .on_line = Effects.lineMsg(.line),    // each stdout line becomes Msg{ .line = ... }
            .on_exit = Effects.exitMsg(.exited),  // the exit becomes Msg{ .exited = ... }
        }),
        .cancel => fx.cancel(stream_key),
        .line => |line| model.recordLine(line),   // copy the bytes — the slice dies with this call
        .exited => |exit| model.finish(exit),
    }
}
```

The process runs on a worker thread the runtime owns; results post to a bounded queue, the platform loop is nudged, and Msgs dispatch on the loop thread — the model is never touched off-thread. Views never spawn: a button dispatches a Msg, and that Msg's update arm spawns.

Boot-time effects go in `.init_fx`, the app's init command: it runs exactly once on the installing frame, before the first view build, so a loading flag set there is in the very first paint and the boot fetch is already in flight when the window appears. Results arrive as ordinary Msgs; it works with either update form.

Line streaming truncates single-line JSON at the 4 KiB line cap, so tools like `gh --json` use collect mode instead: spawn with `.output = .collect` and whole stdout (up to 512 KiB) arrives once on the exit Msg as `exit.output`, together with the child's stderr tail (last 4 KiB) in `exit.stderr_tail` — check it when `exit.code != 0`. No line Msgs fire for a collect spawn, overflow arrives cut with `output_truncated`/`stderr_truncated` set (never silently), and both slices die with the update call — copy what the model keeps.

HTTP requests ride the same channel: one buffered request on a worker thread, delivering its terminal outcome as exactly one Msg — a real response (any status, non-2xx included), a classified failure (`rejected`, `connect_failed`, `tls_failed`, `protocol_failed`, `timed_out`), or `cancelled`:

```ts
case "load":
  return [model, Cmd.fetch(
    { url: asciiBytes("https://api.example.com/search?q=zig") },
    { key: "search", ok: "fetched", err: "fetch_failed" },
  )];
case "stop":
  return [model, Cmd.cancel("search")];
case "fetched":
  // the ok arm carries { status: number, body: Uint8Array } — a 404 is
  // still ok: an HTTP-level error is a delivered response
  return { ...model, status: msg.status, body: msg.body };
```

```zig
.load => fx.fetch(.{
    .key = search_key,                                 // fetches share the spawn slots and key space
    .url = "https://api.example.com/search?q=zig",
    .on_response = Effects.responseMsg(.fetched),      // Msg{ .fetched = native_sdk.EffectResponse }
}),
.stop => fx.cancel(search_key),
.fetched => |response| model.recordResults(response),  // copy response.body — the slice dies with this call
```

Response bodies are binary-safe and bounded (256 KiB; longer arrives cut with `truncated = true`), the whole exchange honors a per-fetch timeout (default 30 s), and cancelling a fetch delivers exactly one `cancelled` Msg with nothing after it.

Files ride the same channel without smuggling an `Io` handle from `main` into `update`. Whole-file reads and writes remain bounded at 1 MiB; `appendFile`, `statFile`, and `deleteFile` handle logs and file lifecycle, while the dedicated streaming family carries large imports and atomic exports. External paths require the `filesystem` permission; app-owned directories are exempt after symlink-safe normalization. See [Files & Streaming](/docs/files).

```zig
.save => fx.writeFile(.{
    .key = save_key,
    .path = model.sessionPath(),
    .bytes = model.snapshotJson(),                 // copied at call time
    .on_result = Effects.fileMsg(.saved),          // Msg{ .saved = native_sdk.EffectFileResult }
}),
.saved => |result| model.noteSaved(result.outcome),
```

Desktop notifications use the same bounded platform-services facade but are intentionally fire-and-forget: once the host accepts a request, the OS can still suppress it through Focus / Do Not Disturb or the user's notification settings, so a success Msg would over-promise. A nonempty `id` replaces an earlier notification with that id. Paired `actionLabel` / `actionCommand` fields add an action whose activation returns through the ordinary application command path while the process is running. Return `Cmd.showNotification` from a TypeScript core or call `fx.showNotification` from a Zig `update_fx` arm; invalid or over-bound fields, unpaired action fields, invalid command names, and unavailable services fail closed. The fake executor and session replay never display one; the null test platform records and can activate one deterministically.

```ts
case "build_finished":
  return [model, Cmd.showNotification({
    id: asciiBytes("build-status"),
    title: asciiBytes("Build finished"),
    subtitle: asciiBytes("native-sdk"),
    body: asciiBytes("All checks passed."),
    actionLabel: asciiBytes("Open results"),
    actionCommand: asciiBytes("build.open-results"),
  })];
```

```zig
.build_finished => fx.showNotification(.{
    .id = "build-status",
    .title = "Build finished",
    .subtitle = "native-sdk",
    .body = "All checks passed.",
    .action_label = "Open results",
    .action_command = "build.open-results",
}),
```

Failure and overflow are always visible: a spawn that cannot run delivers an exit Msg with reason `rejected`, a fetch that cannot run delivers a response Msg with outcome `rejected`, and a file effect that cannot run delivers a result Msg with outcome `rejected`; dropped or truncated lines carry counts and flags; `cancel` kills and reaps the process and always ends in exactly one `cancelled` exit Msg, with no further line Msgs after it. Tests use the fake executor (`effects.executor = .fake`) to assert on spawn, fetch, and file requests and feed synthetic lines, stderr (`feedStderr`, collect spawns), exits, responses, and file results back deterministically — set it before the first frame and `init_fx` boot spawns are recorded too. See `examples/effects-probe`.

For timestamps, the facade owns the clocks (Zig 0.16 puts `std.time` behind `std.Io`, which `update` never sees): `native_sdk.nowMs()` / `nowNanoseconds()` read the wall clock and `monotonicMs()` / `monotonicNanoseconds()` the duration clock. Time-dependent logic stores the `native_sdk.Clock` seam in the model (`.system` by default) so tests substitute a deterministic `native_sdk.TestClock` and advance it by hand.

## Secondary windows

In a default TypeScript app, export `windows(model): readonly WindowDescriptor[]` and put each possible window's markup at `src/windows/<label>.native`; the generated launcher compiles those files, maps descriptor labels to them, and includes shared components they import from nested paths under `src/windows/`. The main `src/app.native` view can import components anywhere under `src/`. Construct descriptors with `windowDescriptor`. `restorePolicy: "center_on_primary"` centers a fresh descriptor with no `x`/`y` on macOS; Windows and Linux currently keep their native default placement. The default policy is `"clamp_to_visible_screen"`. `closePolicy: "quit"` routes `onCloseCommand` through `commandMsg`; `"hide"` retains the window and dispatches no close command. `titlebar` includes `"chromeless"` for fully skinned windows. `examples/system-monitor-ts` is the complete default-path reference.

In Zig cores and custom wiring, windows are model-declared exactly like the tray: `Options.windows_fn` returns the window descriptors that should exist right now (presence IS liveness), and `Options.window_view` builds each declared window's whole canvas tree, keyed by the descriptor's window label. After every dispatched Msg the runtime reconciles: windows the model started declaring are created (a source-less native window wearing one `gpu_surface` view with the descriptor's `canvas_label`, inheriting the main canvas's gpu options), windows it stopped declaring close, and every open window's view rebuilds from the same model — a theme picked in the settings window restyles the main window on the same dispatch. There is no `visible` flag: transient visibility is host state changed through `hideWindow`/`showWindow` or a `.hide` close policy; stop declaring a window to really close it and release its retained views.

```zig
fn windows(model: *const Model, scratch: *App.WindowsScratch) []const App.WindowDescriptor {
    var count: usize = 0;
    if (model.settings_open) {
        scratch.windows[count] = .{
            .label = "settings",
            .canvas_label = "settings-canvas",
            .title = "Settings",
            .width = 360,
            .height = 320,
            .min_width = 320,               // the WINDOW enforces the floor (macOS contentMinSize)
            .min_height = 280,              // — resizes stop instead of clamping panes
            .on_close = .settings_closed,   // the user's close button, as a Msg
        };
        count += 1;
    }
    return scratch.windows[0..count];
}

fn windowView(ui: *App.Ui, model: *const Model, window_label: []const u8) App.Ui.Node {
    _ = window_label; // one declared window; multi-window apps switch on it
    return settingsPane(ui, model);
}

// options: .windows_fn = windows, .window_view = windowView,
```

Input from any window dispatches Msgs through that window's own tree with its window identity. `WindowDescriptor.close_policy` accepts `.quit` (the default) or `.hide`, with the same behavior and platform safeguards as manifest windows. A USER close under `.quit` dispatches the descriptor's `on_close` Msg — the dismissal precedent applied to windows: the window is already gone as an optimistic echo, the model clears its open flag in `update` (or keeps declaring the window and the next rebuild brings it back — source wins). Under `.hide`, the window stays alive with its views, `on_close` does not fire, and `showWindow(label)` brings it back. A close the model itself initiated (it stopped declaring the window) never echoes a Msg. At most `UiApp.max_ui_windows` (4) secondary windows may be declared; the excess warns and is ignored. `min_width`/`min_height` declare a content min-size floor the window itself enforces (macOS `contentMinSize`): the user's resize stops at the floor instead of the layout clamping and clipping panes below their declared minimums — the same fields exist on app.zon windows and `ShellWindow`, and the startup window applies its declaration at the host create like `titlebar`. 0 (the default) leaves the axis at the platform's own minimum.

Markup deliberately binds ONE window's content: there is no `window` element in the closed grammar, because windows are app-shell concerns, not view-tree concerns. A markup-authored secondary window is a `canvas.CompiledMarkupView` whose `build` the `window_view` fn calls for the matching label.

Automation reaches every window with the same verbs: snapshots enumerate all windows and their widgets, and `widget-click`/`widget-hold`/`widget-key`/`screenshot` address a canvas by its view label across all open windows — a settings window's canvas is as drivable as the main one. See `examples/system-monitor` (the gear chip opens a model-declared settings window).

## Hidden titlebar: drag regions and chrome insets

Set `titlebar` to `"hidden_inset"` to extend content under the titlebar while retaining OS window buttons. This is available on macOS, Windows, and Linux. The startup window declaration applies from the first frame. The following channels let an app header reserve space for window controls:

1. **`window-drag="true"`** (builder: `.window_drag = true`) marks an element as a window-drag surface. A press on its background — or on plain text/icons inside it — moves the window (the drag starts only on actual movement; a plain click moves nothing), and a double-click applies the OS titlebar convention (macOS: zoom, honoring the user's titlebar double-click preference). Press fall-through keeps working: a button inside the drag header stays a button, because the press walk claims interactive children before the drag surface.
2. **`on_chrome`** delivers overlay insets and the OS button frame before the first view build and whenever geometry changes. macOS reserves the leading traffic-light area; Windows and Linux report their native control placement. Use these measurements instead of hardcoded padding. Standard-chrome and fullscreen windows report no titlebar overlay. The hook currently covers the main canvas window; TypeScript cores use `chromeMsg` from `@native-sdk/core/events`.
3. The header itself is ordinary markup:

```html
<row padding="10" cross="center" background="surface" window-drag="true" label="Toolbar">
  <spacer width="{chrome_leading}" />   <!-- clears the traffic lights; 0 in fullscreen -->
  <text>My Document</text>
  <spacer grow="1" />
  <button size="sm" on-press="save">Save</button>  <!-- stays clickable -->
</row>
```

`titlebar = "hidden_inset_tall"` is the same shape with the taller unified band: macOS centers the traffic lights in a 52pt band (the Notes/Linear look) instead of the compact one — pick it when the header row is toolbar-height so the lights sit on its centerline. Fullscreen falls back cleanly in both variants.

Windows implements hidden titlebars with DWM non-client handling, and Linux uses GTK client-side decorations. Both report control insets and support drag regions. Mobile hosts use the same chrome channel for safe-area insets; subscribing transfers responsibility for that padding to the app. See [Platform Support](/docs/platform-support) and `examples/markdown-viewer`.

`titlebar = "chromeless"` removes ALL OS chrome — the titleband AND the system buttons, on every desktop (macOS borderless, Windows caption-less popup, Linux undecorated). It is an EXPLICIT opt-in for fully-skinned apps whose chassis draws its own working close/minimize controls wired to the real window-action effects (`fx.closeWindow(label)` / `fx.minimizeWindow(label)`); ordinary apps should keep the hidden styles above, which keep the real OS buttons. Drag regions keep working; chrome insets are zero. See `examples/deck` for the full shape.

## Images

Images are runtime-registered resources. The app chooses an `ImageId` (`u64`; 0 means no image), registers decoded straight-alpha RGBA8 pixels, and binds that id on `image` or `avatar`. Encoded images use platform decoders: CGImageSource on macOS/iOS, gdk-pixbuf on Linux, WIC on Windows, and BitmapFactory on Android. An embed host without an image service reports `error.UnsupportedService`.

Use `fx.loadImage` or `Cmd.imageLoad` to resolve a local or URL source, cache it, decode it, and register it. The result arrives as a message. [Dynamic Images](/docs/dynamic-images) covers cancellation, cache limits, and offline replay. Direct registration is also available when the app already owns encoded bytes or pixels:

```zig
.load => fx.fetch(.{ .key = avatar_key, .url = avatar_url, .on_response = Effects.responseMsg(.fetched) }),
.fetched => |response| {
    if (response.outcome == .ok and response.status == 200) {
        _ = fx.registerImageBytes(avatar_image_id, response.body) catch return;
        model.avatar_image = avatar_image_id;   // only a registered id reaches the view
    }
},
```

```zig
// in a Zig view:
ui.avatar(.{ .image = model.avatar_image, .semantics = .{ .label = "Octocat" } }, "OC"),
ui.image(.{ .image = model.chart_image, .width = 120, .height = 80, .semantics = .{ .label = "Chart" } }),
```

```html
<!-- in markup, image leaves and avatars bind the same model id;
     0 draws nothing / renders the initials -->
<image image="{chart_image}" width="120" height="80" label="Chart" />
<avatar image="{avatar_image}" label="Octocat">OC</avatar>
```

One registered image can be a texture atlas. `ElementOptions.image_src` is an optional `geometry.RectF` in decoded-image pixel coordinates; Native markup spells the same rectangle with all four source attributes. The widget frame stays the destination, a source rectangle crossing the registered image bounds is clipped, and cropped widgets use nearest sampling so adjacent atlas regions cannot bleed into the tile:

```zig
ui.image(.{
    .image = model.atlas_image,
    .image_src = geometry.RectF.init(64, 32, 24, 24),
    .width = 48,
    .height = 48,
    .semantics = .{ .label = "Warning badge" },
})
```

```html
<image image="{atlas_image}" source-x="64" source-y="32"
  source-width="24" source-height="24" width="48" height="48"
  label="Warning badge" />
```

`fx.registerImage(id, width, height, rgba8)` registers already-decoded pixels (the runtime copies them; your buffer is free on return), `fx.registerImageBytes(id, bytes)` decodes through the platform codec first, and `fx.unregisterImage(id)` frees the slot. Re-registering an id replaces its pixels and every view repaints — GPU caches re-upload off the changed content fingerprint, no invalidation calls. For caches, mint fresh ids (effect-key style, monotonically increasing) and unregister the evictee — never re-key different content onto a live id. Outside `UiApp`, the same registry is `Runtime.registerCanvasImage`/`registerCanvasImageBytes`/`unregisterCanvasImage`.

Capacities are fixed and loud (`canvas_limits`): 16 slots with a 1 MiB decoded-pixel target by default. Encoded photos decode aspect-preservingly to fit, while raw `fx.registerImage` pixels remain strict. Image-centric apps may raise the startup-frozen target through app.zon `.images.max_image_pixel_bytes`, up to 8 MiB; storage is lazy per used slot, but filling all 16 ceiling-sized slots is a declared 128 MiB high-water. Every encoded entry point, including direct `fx.registerImageBytes`, shares the flat 8 MiB source bound. `error.ImageRegistryFull`, `error.ImageTooLarge` (encoded source, raw pixels, or a codec-contract violation), `error.ImageDecodeFailed`, and `error.UnsupportedService` are never silent. Registered images render everywhere the canvas does: live presentation, screenshots, and automation. A missing id simply draws its fallback. In tests, the null platform's deterministic strict-PNG decoder also pins exact decode-to-fit dimensions and pixels.

## Rich text: inline spans and markdown

Mixed-style text inside one wrapped paragraph is markup's `<span>`: inline children of a `<text>` element, each styling one run with `weight` (`regular`, `medium` — the semibold rung — or `bold`), `mono`, `italic`, `scale` (a positive multiplier on the paragraph's base size), `underline`, and `foreground` (a color token name). `{bindings}` interpolate inside spans like any text, and the whole paragraph word-wraps as one block:

```html
<text>
  Disk <span weight="bold">{diskUsed}</span> of
  <span foreground="text_muted">{diskTotal}</span> — run
  <span mono="true">native doctor</span> if this looks wrong.
</text>
```

Whitespace between runs collapses to a single space; runs written with no whitespace between them abut, which is how a mono run takes trailing punctuation (`<span mono="true">init.zig</span>.`). Spans do not nest and take no events or keys — layout, identity, and the accessible name stay on the enclosing `<text>` — and the paragraph announces to assistive tech as **one** text run: spans are visual, not structural. Because a span paragraph always word-wraps (reserving its real wrapped height), the single-line `wrap`/`overflow` policies are rejected on it. Both engines lower spans through the same paragraph builder, so a markup span paragraph renders identically to a Zig-built one.

`scale` composes with the paragraph's typography: the run draws (and measures — line breaking is size-aware) at the base size times the multiplier, so `<text size="heading">` with a `scale="1.5"` span gives heading × 1.5, and the multiplier tracks the themable token steps instead of hard-coding a pixel size. One limit: a paragraph reserves ONE uniform line height sized by its largest scale, and every run sits on that line's shared baseline — mixed-scale paragraphs are for inline headings and hero stats next to their captions, not for stacking independent text sizes (stack separate `<text>` elements for that). Only positive finite multipliers are accepted; zero, negative, and non-numeric scales are teaching errors instead of runs silently drawn at the base size. `underline` is a pure decoration — it does not make the run a link.

The full span model — including `strikethrough`, `background` highlights, and link spans — is available from the Zig builder as *spans*:

```zig
const spans = [_]canvas.TextSpan{
    .{ .text = "Run " },
    .{ .text = "zig build test", .monospace = true },
    .{ .text = " then read " },
    .{ .text = "the guide", .link = "https://example.com/guide" },
};
ui.paragraph(.{ .on_link = Ui.linkMsg(.open_url) }, &spans)
```

Wrapping and measurement are span-aware (the platform text provider measures every run with the font it draws with), stacked paragraphs reserve their real wrapped height, and link spans are interactive: they carry `role=link` semantics in automation snapshots, show the pointing-hand cursor (the only place the engine uses it — controls keep the platform arrow, following native convention rather than the web's), and clicking one dispatches your `Msg` with the link payload.

Source presentation is the reusable `ui.code` / `<code>` component. It is deliberately bare: no background, border, radius, shadow, or padding. Wrap it in a `panel`, `card`, or another container when the design needs surface chrome. It wraps by default; `wrap="false"` preserves logical lines inside one horizontal scroll region, and `line-numbers` opts into a muted logical-line gutter. A definite `height` keeps overflow reachable with vertical scrolling (or both axes when wrapping is off):

```html
<code source="{component_source}" language="tsx" line-numbers wrap="false" width="480" label="Accordion example" />
```

```zig
ui.code(.{
    .language = .html,
    .line_numbers = true,
    .wrap = false,
    .width = 480,
}, model.component_source)
```

For example, an inset code well is ordinary composition:

```html
<panel padding="12">
  <code source="{component_source}" language="tsx" />
</panel>
```

Recognized languages receive theme-aware syntax color for keywords, types/numbers, strings, and comments: Zig; JavaScript/TypeScript; JSX/TSX; JSON; YAML; shell; Python; Rust; C, C++, C#, Java, Kotlin, and Swift; Go; HTML/XML/SVG; CSS/SCSS/Less; and SQL. HTML-family highlighting distinguishes element and component tags, attributes, strings, comments, and JavaScript/TypeScript expressions. Unknown or omitted tags stay plain monospace, and the bounded highlighting fallback never drops code. See [Code](/docs/components/code).

Markdown builds on the same model. `native_sdk.markdown` maps a GitHub-flavored subset — headings, inline styles, links (including bare `http(s)://` URLs, which autolink with trailing punctuation trimmed), bullet/ordered/task lists, fenced code blocks, blockquotes, rules, pipe tables, and `<details>` collapsibles — onto ordinary widgets:

```zig
const Md = native_sdk.markdown.Markdown(Msg);
Md.view(ui, issue.body, .{
    .images = model.markdown_images, // successful fx.loadImage mappings
    .on_link = Ui.linkMsg(.open_url),
    .on_details = Md.detailsMsg(.toggle_details),
    .details_expanded = &model.details_expanded, // caller-owned flags, elm-style
    .issue_link_base = "ghissue://", // opt-in: "#123" links to ghissue://123
})
```

Malformed input degrades to plain text — the build fn never fails. Task-list checkboxes render as disabled (display-only) checkboxes, and `<details>` expansion is state the caller's model owns.

Fenced code preserves every source indentation level and lowers through the same `ui.code` component, including JSX/TSX-aware HTML-family highlighting. Markdown keeps the component defaults: wrapping on, line numbers off.

GFM pipe tables map onto the real `table`/`table-row`/`table-cell` widgets: the header row renders bold, the delimiter row's `:---`/`:--:`/`---:` cells set per-column start/center/end alignment, every cell runs the full inline grammar (code, bold, links — links in cells are clickable), `\|` puts a literal pipe in a cell, and cells wrap at their column width (columns share the width equally in v1). A pipe block whose delimiter row is missing or mismatched is not a table and renders as plain paragraphs.

In markup, the `<markdown>` element wires all of this declaratively:

```html
<markdown source="{issue_body}" images="{markdownImages}" on-link="open_url" on-details="toggle_details" details-expanded="{details_expanded}" issue-link-base="ghissue://" />
```

`source` (required) is one `{binding}` producing the markdown text — a string field, zero-arg method, or arena-taking method. `images` optionally binds `[]const canvas.markdown.ResolvedImage`: give `canvas.markdown.collectImageSources` a caller-owned `[]canvas.markdown.CollectedImageSource`, consume each canonical source through `value()` while that storage is alive, load it through `fx.loadImage`, and retain the source, successful id, and dimensions in the model. Return those mappings from a model or arena-taking method; the view performs no I/O. `on-link` and `on-details` are bare `Msg` tags (the runtime supplies their payloads: the URL as `[]const u8`, the details index as `usize`), `details-expanded` names a `[]const bool` iterable through the same sources `for each` accepts, and `issue-link-base` (a literal prefix or one `{binding}`) turns `#123` references into links to base ++ number. Everything but `source` is optional — without the details wiring, `<details>` blocks render collapsed and inert. Both the interpreter and the comptime compiler implement the element identically.

## Pipeline components: stepper, timeline, and nav

Pipeline UIs (agent runs, wizards, activity feeds) get three composites. All three are ordinary compositions of existing widgets — same layout, theming, semantics, and hit-testing as hand-written trees.

A **stepper** is a horizontal stage track (the house stepper conventions: indicator + title per step, separators as connectors). The model owns the active index; earlier steps render completed (a check badge), later ones pending:

```html
<stepper active="{stage_index}">
  <step>Work</step>
  <step>Triage</step>
  <step>Review · {review_round}</step>
  <step>Fix</step>
  <step>Ready</step>
</stepper>
```

From Zig: `ui.stepper(.{ .active = model.stage_index }, &.{ .{ .label = "Work" }, ... })`. Semantics: a `list` of `listitem`s, the active step `selected`, each label carrying its state (`"Review (active)"`) plus its position, so automation can read the pipeline at a glance.

A **timeline** is a ledger list (the house timeline conventions: indicator + separator rail + title/description/meta per item). Items are leaves; the attributes carry the content:

```html
<timeline gap="4">
  <for each="ledger" key="slot" as="entry">
    <timeline-item title="{entry.title}" description="{entry.summary}" meta="{entry.meta}"
                   variant="{entry.tone}" on-press="open_step:{entry.slot}" />
  </for>
  <timeline-item title="Ready for review" indicator="OK" variant="secondary" connector="false" />
</timeline>
```

The leading badge is a small dot colored by `variant` (map run outcomes: `primary` for done, `destructive` for errors, `outline` for stopped, ...) or carries `indicator` text ("OK"); `connector="false"` ends the rail on the last item. With `on-press`, the item grows a trailing chevron and the press binds to the item's root, focusable with role `listitem` — clicks on the title/description/meta fall through to it, so a click anywhere on the item dispatches the message (dragging still selects the text). There is no built-in hover fill or line-clamping on the description in v1.

A **nav** container is the within-pane navigation stack (ledger item → step transcript, back button pops). Zig-only for now — markup swaps views with `<if>`:

```zig
ui.nav(.{ .active = model.nav_depth, .retain = true }, .{
    ledgerPane(ui, model),
    transcriptPane(ui, model),
})
```

The model owns the stack (push = set `nav_depth = 1`, pop = back to 0; out-of-range clamps). Pages are index-keyed, so a page's widget ids — and with them engine-owned scroll offsets and text edits — are stable across swaps. `retain = true` keeps inactive pages mounted but hidden: they keep engine state and drop out of rendering, hit-testing, focus traversal, and semantics; the default unmounts them (retained state is dropped). v1 swaps instantly — there is no push/pop animation yet — and focus does not transfer automatically: move it from `update` when the focused widget lives on the outgoing page.

## Charts

The `<chart>` element is the data-visualization composite: `<series>` children bind model iterables of `f32` and draw through the vector path pipeline with token colors, so charts re-theme with the palette, repaint exactly when their data changes, and report their series in automation snapshots. Three series kinds cover time-series dashboards:

```html
<chart grow="1" height="220" y-min="0" grid-lines="3" label="Star history">
  <series kind="area" values="{sdkStars}" color="accent" label="native-sdk" />
  <series kind="line" values="{examplesStars}" color="info" label="examples" />
</chart>
```

- **`line`** — a polyline through the values. A single sample renders as a dot.
- **`area`** — a line with a translucent fill down to the baseline (the builder's `fill = true` spelling).
- **`bar`** — one bar per value, always anchored at zero (the auto domain forces 0 in; negative values hang below the zero line). A zero value draws nothing — zero looks like zero.

Series `values` take one `{binding}` naming a `[]const f32` iterable — a model field, public declaration, or function (arena functions work), the same resolution set as `for each`, slice-valued template args included. Values are y samples at uniform x steps, oldest first; `NaN` marks a missing sample and draws a gap, so pad a filling window with leading `NaN` in a model function and the trace enters from the right. Colors are design-token references (`accent`, `info`, `success`, `warning`, `destructive`, ...), never raw colors, so charts hold up in both themes. The y domain derives from the data per side unless `y-min`/`y-max` pin it (literals or scalar bindings); `grid-lines` draws that many horizontal token hairlines (opt-in — none by default) and `baseline="true"` marks the zero line.

Axis tick labels are opt-in and draw in the muted text register inside reserved gutters. `x-labels="{binding}"` names a model iterable of strings — one category label per sample, oldest first — thinned deterministically to every Nth so labels never collide (and dropped entirely when a series downsamples: bucketed indices no longer name the labeled samples). `y-labels="true"` adds numeric ticks on a nice-step lattice (1/2/5 × 10^k, exact at their precision); with `grid-lines` set, the gridlines ride the same lattice, so the grid and its labels can never disagree.

`hover-details="true"` adds pointer-hover point details: the pointer snaps to the nearest sample, a hairline cursor marks it (line series get a dot on the hovered point), and a floating card shows the sample's label with every series' name and value. Interaction-only chrome — static renders never show it.

Series over 256 points use deterministic index-bucket min/max decimation to stay within the path budget while retaining extrema. This limit describes the rendering algorithm, rather than a measured performance result.

Semantics: role `chart`, a generated series summary as the label (`"chart: cpu 3 pts last 0.75"` — describing the source series, pre-decimation) unless you set one, and the first series' latest value as the accessibility value, so automation can assert on live data without pixel access. Charts never claim presses: clicks fall through to the nearest pressable ancestor, so a chart inside a pressable row keeps the row clickable (`hover-details` makes the chart a hover target only).

The series set is static — the plot's shape is part of the design and the DATA varies through the bindings. `ui.chart` (the same code the markup lowers through) is the escape hatch for what the grammar deliberately excludes: `.band` envelopes (a min/max band pairs a second lower-edge slice per point) and series sets composed dynamically at view time. See [Chart](/docs/components/chart) for the full attribute tables.

## Accessibility

Every interactive element needs an accessible name — the text a screen reader announces and automation snapshots address widgets by. Name sources are the element's text content, the `text` attribute, or `label="..."`; text-entry controls take `label` or `placeholder` (the accessibility bridges announce the placeholder as the fallback name). The sources never combine: an explicit `label` REPLACES the element's text as the accessible name — while a label is set, the visible text is neither announced nor targetable, so never label an element whose visible text your automation greps for.

This is a machine check, not a review item. An unnamed control, an icon-only control without a label (the icon name is a drawing instruction, not a label), or a misused role (`role="tree"` on an element that can never hold rows) is a validation error in `native markup check`, both view engines, and the LSP — the rubric is whether a screen reader user is fully blocked. Unnamed images and labels that duplicate the text they shadow degrade rather than block, so they report as warnings (`--strict` promotes them); the explicit `label=""` marks an image decorative on purpose.

Zig-built views get the same discipline at tree level: `canvas.expectA11yAuditSweepClean` lays out the real tree and reports interactive widgets announced with no name (including dynamic labels that resolve empty at runtime), focusable widgets keyboard traversal can never reach, and identically labeled sibling controls — adopt it in your test suite next to `canvas.expectLayoutAuditSweepClean`. Contrast checking and focus-visible styling checks are not part of the audit yet.

## Tooling

- `native markup check src/app.native` — instant validation with `file:line:column` errors, including the font-coverage tofu guard: literal text with a codepoint outside the bundled face is a teaching error naming the character (it renders as a tofu box on the reference/screenshot and mobile paths — [register a font](/docs/fonts) that covers it and bind the text from the model, or use a vector icon or plain words; the static check knows only the bundled face's coverage). Dynamic strings get the same lesson as a Debug-build diagnostic when the view builds. Accessibility findings ride the same pass: unnamed interactive controls and role misuse are errors, unnamed images and redundant labels are warnings (see [Accessibility](#accessibility)).
- `native markup lsp` — diagnostics, completion, and hover in your editor (see `editors/native-markup/` in the repo for VS Code, Helix, and Neovim setup).
- The automation harness drives widgets and reads accessibility snapshots and screenshots headlessly — see [Automation](/docs/automation).

### The model–view contract

The model-contract artifact — `zig-out/model-contract.zon`, a reflection of your `Model`/`Msg` — is refreshed by `native test` in every app shape (apps that own their build can also run `zig build model-contract` directly). With that artifact fresh, `native check` verifies markup against your app's actual surface — no app compile in the loop:

- View → model: every binding path, `for each` iterable, `key` field, message tag, and payload type must exist on the model with the right shape, and expressions type-check with the real binding types — `{count > 'a'}` is an error naming `count` and its core type. Unknown names get a did-you-mean over your model's actual fields.
- Model → view: model fields, derived helpers/query functions, and `Msg` tags that no view binds or dispatches are reported as **warnings**. State only `update` or effects logic touches is legitimate, so opt it out in the core's vocabulary: TypeScript declares `export const viewUnbound = ["nextId", "tick"] as const` in `src/core.ts`; Zig declares `pub const view_unbound = .{ "next_id", "tick" };` on `Model` or `Msg`. Use the names exactly as that core authored them. `--strict` promotes the warnings to failures.
- Template args are part of a template's interface: the kinds of use-site arguments flow into the template body (a string passed into a `width` arg fails at the use), slot content checks in the consumer's scope, and the check follows the whole `<import>` closure.

A missing or stale artifact (the artifact carries a hash of the app's core sources) degrades to structural checking with one loud line ("model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks") — never a false pass; the compiled engine still enforces the same contract at build time, and a conformance suite holds the two checkers to identical accept/reject sets. Model state consumed only by a Zig-built view needs `view_unbound` too — the markup checker cannot see Zig view reads.

For the component catalog and styling model, see [Built-in Components](/docs/built-in-components). For dropping down to the programmatic API, the `canvas.Ui(Msg)` builder produces exactly the same trees the markup compiles to.

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)