# Native SDK Documentation

> Technical reference for the Native SDK toolkit: declarative .native markup, Zig app model, CLI workflows, automation, packaging, and platform hosts for building native desktop apps without a browser runtime.

## Context Links

- [Agent index](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/llms.txt)
- [Human interactive docs](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a)
- [GitHub repository](https://github.com/vercel-labs/native)

## Repository Metadata

- Repository: vercel-labs/native

- Generated: 2026-07-09T08:24:49.332Z
- Updated: 2026-07-09T08:25:27.078Z
- Runtime: Grok CLI
- Format: Documentation
- Pages: 27

## Page Index

- 01. [Overview](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/01-overview.md) - What Native SDK exposes, who should use it, runtime assumptions, and the first docs routes from install to a running window.
- 02. [Installation](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/02-installation.md) - Install @native-sdk/cli, platform package layout, prerequisites, and success signals for a working native binary on each host OS.
- 03. [Quickstart](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/03-quickstart.md) - Create an app with native init, run native dev, edit .native markup and Zig Model/Msg/update, and verify the first live window.
- 04. [Agent skills](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/04-agent-skills.md) - Shipped skill packs for core authoring, Native UI, and automation, including how agents discover and apply them via the CLI.
- 05. [App model](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/05-app-model.md) - Model, Msg, and update loop wiring, hot reload boundaries, and how markup binds values without mutating state.
- 06. [Native UI markup](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/06-native-ui-markup.md) - Elements, attributes, flex layout, bindings, expressions, and editor/LSP support for .native views compiled into the binary.
- 07. [State and data flow](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/07-state-and-data-flow.md) - Derive-don't-store patterns, message dispatch, text editing paths, and effects as the only channel for side effects.
- 08. [Theming](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/08-theming.md) - Design tokens for color, radius, and typography, live theme re-resolution, chrome passes, and token-only restyles across apps.
- 09. [Components](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/09-components.md) - Built-in component catalog inventory, preview generation, eject workflows, and identity constraints for reusable widgets.
- 10. [Windows and surfaces](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/10-windows-and-surfaces.md) - Multi-window apps, native surface composition, OS window chrome, GPU surfaces, and host presentation on macOS and desktop peers.
- 11. [Menus, dialogs, and tray](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/11-menus-dialogs-and-tray.md) - App menus, context menus, system dialogs, tray status items, and how OS-owned UI coexists with engine-drawn widgets.
- 12. [Commands and keyboard shortcuts](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/12-commands-and-keyboard-shortcuts.md) - Single command routing across toolbar, menu, tray, shortcut, and bridge entry points with keyboard binding constraints.
- 13. [Capabilities](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/13-capabilities.md) - Guarded OS services for notifications, clipboard, credentials, dialogs, file drops, and effect-channel access boundaries.
- 14. [Web engines](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/14-web-engines.md) - WebView composition on desktop hosts, CEF tooling and hosts, engine flags, and when native-rendered UI coexists with web content.
- 15. [Bridge and builtin commands](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/15-bridge-and-builtin-commands.md) - Host-to-content bridge payloads, responses, async dispatch, builtin commands, and permission boundaries for WebView apps.
- 16. [Frontend projects](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/16-frontend-projects.md) - Managed React, Next, Svelte, and Vue shells, frontend asset pipeline, and native dev server integration for web frontends.
- 17. [Embedded app](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/17-embedded-app.md) - C embed ABI, host view controllers, mobile canvas libraries, and experimental iOS/Android host shims around the desktop-first runtime.
- 18. [Automation](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/18-automation.md) - Embedded automation server: accessibility snapshots, widget driving, record/replay, deterministic screenshots, and agent command surface.
- 19. [Testing](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/19-testing.md) - Full-loop UI tests via native test, headless truth drivers, effect tests, and frame-level verification without a display server dependency.
- 20. [Testing in CI](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/20-testing-in-ci.md) - CI workflows, gate scripts, platform truth drivers, and eval harness wiring for reproducible headless and hosted checks.
- 21. [Packaging](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/21-packaging.md) - From ReleaseFast binaries to distributable app bundles: assets, build graph outputs, and packaging layout expectations.
- 22. [Code signing](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/22-code-signing.md) - Codesign tooling, framework and native binary copy steps, and signing constraints for macOS and related package pipelines.
- 23. [Updates and package distribution](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/23-updates-and-package-distribution.md) - Update channels, npm multi-platform package layout, version sync checks, and binary distribution for @native-sdk packages.
- 24. [CLI reference](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/24-cli-reference.md) - native init, dev, check, test, build, automate, doctor, and skills commands with flags, outputs, and failure modes.
- 25. [Runtime and effects](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/25-runtime-and-effects.md) - Runtime API surface, effect kinds for spawn/fetch/file/audio/clipboard, cancellation, worker wakes, and deterministic frame limits.
- 26. [Platform support and security](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/26-platform-support-and-security.md) - Host capability matrix for macOS, Linux, Windows, and experimental mobile, plus bridge permission and capability security boundaries.
- 27. [Contributing](https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/27-contributing.md) - Local development setup, agent guidance, release and changelog merge process, and pre-1.0 contribution expectations.

## Source File Index

- `.github/workflows/ci.yml`
- `AGENTS.md`
- `build.zig`
- `build/app.zig`
- `CHANGELOG.md`
- `CONTRIBUTING.md`
- `docs/src/app/dialogs/layout.tsx`
- `docs/src/app/embed/layout.tsx`
- `docs/src/app/frontend/layout.tsx`
- `docs/src/app/keyboard-shortcuts/layout.tsx`
- `docs/src/app/menus/layout.tsx`
- `docs/src/app/native-surfaces/layout.tsx`
- `docs/src/app/native-ui/layout.tsx`
- `docs/src/app/packages/layout.tsx`
- `docs/src/app/packaging/layout.tsx`
- `docs/src/app/packaging/signing/layout.tsx`
- `docs/src/app/quick-start/layout.tsx`
- `docs/src/app/runtime/layout.tsx`
- `docs/src/app/security/layout.tsx`
- `docs/src/app/skills/layout.tsx`
- `docs/src/app/state/layout.tsx`
- `docs/src/app/testing/ci/layout.tsx`
- `docs/src/app/testing/layout.tsx`
- `docs/src/app/theming/layout.tsx`
- `docs/src/app/tray/layout.tsx`
- `docs/src/app/updates/layout.tsx`
- `docs/src/app/web-engines/layout.tsx`
- `docs/src/app/windows/layout.tsx`
- `docs/src/components/component-preview.tsx`
- `docs/src/components/eject-section.tsx`
- `docs/src/components/home/install-toggle.tsx`
- `docs/src/lib/component-vocab.json`
- `docs/src/lib/components-pages.ts`
- `docs/src/lib/docs-navigation.ts`
- `editors/native-markup/extension.js`
- `editors/native-markup/README.md`
- `evals/README.md`
- `examples/browser/frontend/app.js`
- `examples/calculator/README.md`
- `examples/capabilities/build.zig`
- `examples/capabilities/README.md`
- `examples/command-app/build.zig`
- `examples/command-app/README.md`
- `examples/deck/README.md`
- `examples/deck/src/chrome.zig`
- `examples/effects-probe/README.md`
- `examples/hello/build.zig`
- `examples/ios/README.md`
- `examples/mobile-canvas/build.zig`
- `examples/next/build.zig`
- `examples/react/build.zig`
- `examples/README.md`
- `examples/soundboard/README.md`
- `examples/webview/README.md`
- `packages/native-sdk/npm/darwin-arm64/README.md`
- `packages/native-sdk/README.md`
- `packages/native-sdk/scripts/build-binaries.sh`
- `packages/native-sdk/scripts/check-version-sync.js`
- `packages/native-sdk/scripts/copy-framework.js`
- `packages/native-sdk/scripts/copy-native.js`
- `README.md`
- `RELEASING.md`
- `scripts/changelog-merge.sh`
- `scripts/gate.sh`
- `skill-data/automation/SKILL.md`
- `skill-data/core/references/app-model-runtime.md`
- `skill-data/core/references/bridge-security-native-capabilities.md`
- `skill-data/core/references/frontend-assets.md`
- `skill-data/core/SKILL.md`
- `skill-data/native-ui/SKILL.md`
- `skills/native-sdk/SKILL.md`
- `src/embed/c_api.zig`
- `src/embed/chrome.zig`
- `src/embed/host.zig`
- `src/platform/linux/cef_host.cpp`
- `src/platform/linux/gtk_host.h`
- `src/platform/macos/appkit_host.m`
- `src/platform/windows/cef_host.cpp`
- `src/primitives/canvas/themes/geist.zig`
- `src/runtime/api.zig`
- `src/runtime/async_bridge.zig`
- `src/runtime/automation_commands.zig`
- `src/runtime/automation_snapshot.zig`
- `src/runtime/automation_widget_dispatch.zig`
- `src/runtime/bridge_payload.zig`
- `src/runtime/bridge_responses.zig`
- `src/runtime/builtin_bridge.zig`
- `src/runtime/canvas_widget_context_menu.zig`
- `src/runtime/canvas_widget_runtime.zig`
- `src/runtime/core.zig`
- `src/runtime/effects_tests.zig`
- `src/runtime/effects.zig`
- `src/runtime/flow.zig`
- `src/runtime/gpu_surface_events.zig`
- `src/tooling/android.zig`
- `src/tooling/assets.zig`
- `src/tooling/buildgraph.zig`
- `src/tooling/cef.zig`
- `src/tooling/codesign.zig`
- `src/tooling/dev.zig`
- `src/tooling/doctor.zig`
- `src/tooling/eject_components.zig`
- `src/tooling/embedlib.zig`
- `src/tooling/ios.zig`
- `tests/README.md`
- `third_party/cef/README.md`
- `tools/linux-truth/drive.sh`
- `tools/linux-truth/README.md`
- `tools/native-sdk/automation.zig`
- `tools/windows-truth/README.md`

---

## 01. Overview

> What Native SDK exposes, who should use it, runtime assumptions, and the first docs routes from install to a running window.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/01-overview.md
- Generated: 2026-07-09T08:16:27.495Z

### Source Files

- `README.md`
- `packages/native-sdk/README.md`
- `docs/src/lib/docs-navigation.ts`
- `build.zig`
- `examples/README.md`

---
title: "Overview"
description: "What Native SDK exposes, who should use it, runtime assumptions, and the first docs routes from install to a running window."
---

Native SDK is a Zig-backed toolkit for desktop apps: declarative `.native` views, plain Zig `Model` / `Msg` / `update`, and a pixel engine that draws into real OS windows without a browser, WebView, or markup interpreter in release binaries. The primary author surface is the `native` CLI (`@native-sdk/cli`, currently versioned with the framework packages), which scaffolds apps, owns the zero-config build graph, validates markup, runs headless UI tests, packages hosts, and ships agent skill packs.

## What the SDK exposes

| Surface | Role |
| --- | --- |
| **CLI** (`native`) | `init`, `dev`, `build`, `test`, `check`, `eject`, `doctor`, `validate`, `package`, `automate`, `skills`, `markup`, `version` |
| **App model** | `native_sdk.UiApp(Model, Msg)` / `UiAppWithFeatures` wires Model, Msg, `update`, scene, and markup into the runtime |
| **Markup** | `.native` files: elements, flex layout, `{bindings}`, expressions, message dispatch; compile-time `canvas.CompiledMarkupView` in release |
| **Runtime** | Windows, GPU / software presentation, timers, commands, effects (spawn / fetch / file / audio / clipboard), automation server |
| **Manifest** | `app.zon` — identity, windows, permissions, capabilities, bridge policy, security, web engine, packaging inputs |
| **OS hosts** | Platform modules under `src/platform/` for macOS, Linux, Windows; experimental embed / mobile hosts via C ABI |
| **Optional web path** | System WebView (and CEF Chromium on macOS) with bridge commands and managed frontend shells |
| **Agent skills** | Shipped packs: `core`, `native-ui`, `automation` via `native skills list\|get` |

The public Zig root (`src/root.zig`) re-exports the runtime (`Runtime`, `App`, `UiApp`, effects, clocks, test harness), platform types (windows, WebViews, GPU surfaces), automation, embed, bridge, frontend helpers, and security symbols. App authors typically depend on that module through the CLI-bundled framework rather than composing platform hosts by hand.

### Two app architectures

```text
  Default (native-rendered)              Optional (WebView shell)
  ─────────────────────────              ────────────────────────
  .native  +  Model/Msg/update           frontend/ + App + WebViewSource
        │                                      │
        ▼                                      ▼
   UiApp canvas tree                      Runtime WebView host
        │                                      │
        └──────── OS window / chrome ──────────┘
                    menus · tray · dialogs
                    (host-owned where supported)
```

- **Native-rendered (default):** `native init` scaffolds `src/app.native` + `src/main.zig` + `src/tests.zig` + `app.zon`. The engine owns every pixel; markup binds and dispatches only — it never mutates state.
- **WebView shell:** `native init --frontend next|vite|react|svelte|vue` (or examples like `hello` / `webview`) loads web content in a platform WebView. Zig still owns lifecycle, windows, bridge policy, and packaging. One app can mix native canvas surfaces and WebViews.

## Who should use it

| Audience | Fit |
| --- | --- |
| Desktop app authors who want declarative UI + typed state without shipping a browser runtime | Primary |
| Teams shipping small native binaries with design tokens and a component catalog | Primary |
| Humans and agents co-authoring UI (skills + automation + `native check` diagnostics) | Primary |
| Products that need OS chrome (menus, tray, dialogs, credentials, notifications) behind explicit permissions | Primary |
| Apps that already have a web frontend and need a native shell | Secondary (`--frontend`, bridge, WebView examples) |
| Mobile hosts / embed consumers | Experimental (iOS/Android embed ABI; desktop remains mature) |

Native SDK is **pre-1.0**: APIs still move. Prefer focused app work and issue discussion for large design changes. License: Apache-2.0.

## Runtime assumptions

| Assumption | Detail |
| --- | --- |
| **Desktop-first hosts** | macOS is the deepest host (Metal presentation, OS scroll physics, native menus/tray/dialogs). Linux and Windows run full desktop apps (software renderer presentation, IME, CI truth drivers). |
| **State loop** | Exactly one mutation site: `update(model, msg)`. Markup and views re-derive UI; side effects go through the effects channel, not ad-hoc mutation in views. |
| **Build ownership** | Zero-config apps (`app.zon` + `src/`) let the CLI generate the build under `.native/build/`. `native eject` or `native init --full` writes owned `build.zig` / `build.zig.zon`. |
| **Toolchain** | Apps compile with Zig. Compatible `zig` on `PATH` is used when present; otherwise `native dev\|build\|test` can fetch the pinned toolchain into `~/.native/toolchains/`. |
| **SDK location** | `native init` records the framework path from the installed CLI (or `--framework`). The npm package ships SDK sources so init/dev work offline after install (no install scripts; platform binaries are optionalDependencies). |
| **Debug vs release markup** | Debug + `watch_path` enables hot reload of `.native` files without losing model state. Release embeds compile-time compiled markup — no parser in the binary; markup mistakes become compile errors. |
| **Headless truth** | Tests and automation can run without a display server via null / headless drivers; live automation uses a file-based protocol under `.zig-cache/native-sdk-automation/`. |
| **Security defaults** | WebView content is untrusted: permissions, bridge command origins, and navigation allowlists in `app.zon` gate access. Unsupported platform services return explicit errors (for example Linux tray → `UnsupportedService`). |

### Host capability snapshot

| Capability | macOS | Windows | Linux | Mobile (experimental) |
| --- | --- | --- | --- | --- |
| Real OS windows | Full | Full | Full | Full-screen toolkit host |
| Native rendering | Metal + OS scroll | Software + GDI | Software + cairo | Software via embed hosts |
| Menus / tray | Full | App menus + tray; context menus as canvas | App menus; tray unsupported | None |
| WebView | System (+ CEF Chromium optional) | System (maturing) | System WebView | Embed / host shells |
| Packaging | `.app` / DMG | Directory artifact | Install tree | Generated Xcode / APK hosts |
| Code signing tooling | Ad-hoc + identity | — | — | Manual |

Unsupported operations fail explicitly rather than pretending host parity. See [Platform support and security](/platform-security) for the full matrix and bridge permission boundaries.

## Package and install layout

Install the CLI:

```bash
npm install -g @native-sdk/cli
native version
```

Expected version line shape: `native <version> (commit <hash>, automation protocol v<n>)`.

| Package | Contents |
| --- | --- |
| `@native-sdk/cli` | `bin/native.js`, SDK sources (`src/`, `build/`, `build.zig*`, `app.zon`), `skills/`, `skill-data/` |
| `@native-sdk/cli-<platform>` | Optional per-OS native binary (darwin-arm64/x64, linux-arm64/x64 gnu/musl, win32-arm64/x64) |

First-build toolchain install may prompt; pass `--yes` for non-interactive scripts. Success signal after init/dev: a native window opens with the scaffolded counter (or frontend shell) and `native check` reports markup + manifest OK.

## From install to a running window

<Steps>
  <Step title="Install the CLI">
    `npm install -g @native-sdk/cli` then `native version`. Confirm the binary resolves and prints version + automation protocol.
  </Step>
  <Step title="Scaffold">
    `native init my_app` creates a zero-config native-rendered app: `src/app.native`, `src/main.zig`, `src/tests.zig`, `app.zon`, `assets/icon.png`. Use `--frontend <…>` for a web shell or `--full` for owned build files and CI scaffold.
  </Step>
  <Step title="Run">
    `cd my_app && native dev`. First run compiles the app and SDK (slower); later runs are incremental. Debug build arms markup hot reload on `src/app.native`.
  </Step>
  <Step title="Verify">
    `native check` validates every `src/**.native` and `app.zon` without a full build. `native test` runs full-loop UI tests headlessly. `native build` emits `zig-out/bin/<app-name>` (ReleaseFast).
  </Step>
</Steps>

### Scaffold shape (native default)

:::files
my_app/
  app.zon                 # identity, windows, permissions, security
  src/
    app.native            # UI: layout, bindings, on-press messages
    main.zig              # Model, Msg, update, UiApp wiring, shell scene
    tests.zig             # headless full-loop UI tests
  assets/
    icon.png              # single square source for packaging icons
:::

### Authoring loop

```zig
// src/main.zig — state only changes here
pub fn update(model: *Model, msg: Msg) void {
    switch (msg) {
        .increment => model.count += 1,
        .decrement => model.count -= 1,
        .reset => model.count = 0,
    }
}
```

```html
<!-- src/app.native — binds and dispatches; never mutates -->
<button variant="secondary" on-press="decrement">-</button>
<text>{count}</text>
<button variant="primary" on-press="increment">+</button>
```

While `native dev` runs, edit the markup: the window updates within about two seconds and keeps model state. Parse failures keep the last good view and surface `file:line:column` diagnostics (`native markup lsp` for editor integration).

### Repository examples

Most apps under `examples/` are zero-config (`app.zon` + `src/`). From an example directory (or with repo-built `zig-out/bin/native`):

| Path | Shows |
| --- | --- |
| `examples/habits` | Smallest native markup app |
| `examples/calculator` | Keypad, keyboard path, chrome shortcuts, theming |
| `examples/notes` | Effects channel persistence, dialogs, search |
| `examples/feed` | 100k-row virtualized list |
| `examples/soundboard` / `deck` | Same widgets, different tokens / multi-window |
| `examples/hello` / `webview` | WebView shell and bridge policy |
| `examples/capabilities` | Guarded OS services |
| `examples/next`, `react`, `svelte`, `vue` | Managed frontend shells |

## Architecture layers

```mermaid
flowchart TB
  subgraph authoring [Author surfaces]
    CLI["native CLI"]
    Markup[".native markup"]
    ZigApp["Model / Msg / update"]
    Manifest["app.zon"]
    Skills["skill-data packs"]
  end

  subgraph sdk [SDK modules]
    UiApp["runtime UiApp"]
    Canvas["primitives/canvas"]
    Runtime["runtime + effects"]
    Bridge["bridge + security"]
    Auto["automation server"]
  end

  subgraph host [Host]
    Plat["platform: macOS / Linux / Windows"]
    OSWin["OS windows · menus · tray · dialogs"]
    OptionalWeb["optional WebView / CEF"]
    Embed["embed C ABI · experimental mobile"]
  end

  CLI --> Markup
  CLI --> ZigApp
  CLI --> Manifest
  CLI --> Skills
  Markup --> UiApp
  ZigApp --> UiApp
  Manifest --> Runtime
  UiApp --> Canvas
  UiApp --> Runtime
  Runtime --> Plat
  Runtime --> Auto
  Bridge --> OptionalWeb
  Plat --> OSWin
  Plat --> OptionalWeb
  Runtime --> Embed
```

## Agent and automation surface

Every app can embed the automation server for accessibility snapshots, widget driving, assertions, deterministic CPU-reference screenshots, and record/replay (`native automate …`). Skill packs ship with the CLI:

| Skill | Use when |
| --- | --- |
| `core` | Project anatomy, runtime, frontend, bridge, packaging |
| `native-ui` | `.native` authoring, Model/Msg/update, UiApp |
| `automation` | Driving a running app, smoke tests, screenshots |

```bash
native skills list
native skills get native-ui
native skills get core --full
```

Skills are portable file/catalog content, not tied to a particular model provider. Agents should load skills before inventing APIs from general training data.

## Status and contribution boundary

- **App authors:** install `@native-sdk/cli`, follow install → init → dev → check/test.
- **Toolkit contributors:** Zig 0.16+, `zig build test`, `scripts/gate.sh fast` for affected suites; see [Contributing](/contributing). Do not treat pre-1.0 APIs as stable contracts without checking current sources.

## Next

<CardGroup>
  <Card title="Installation" href="/installation">
    Install `@native-sdk/cli`, platform package layout, prerequisites, and success signals per host OS.
  </Card>
  <Card title="Quickstart" href="/quickstart">
    `native init`, `native dev`, edit markup and Zig, verify the first live window.
  </Card>
  <Card title="App model" href="/app-model">
    Model, Msg, update loop, hot reload boundaries, and markup binding rules.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    Full command list: init, dev, check, test, build, automate, doctor, skills.
  </Card>
  <Card title="Agent skills" href="/agent-skills">
    Discover and apply shipped skill packs for authoring and automation.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix and capability / bridge security boundaries.
  </Card>
</CardGroup>

---

## 02. Installation

> Install @native-sdk/cli, platform package layout, prerequisites, and success signals for a working native binary on each host OS.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/02-installation.md
- Generated: 2026-07-09T08:18:18.648Z

### Source Files

- `README.md`
- `packages/native-sdk/README.md`
- `packages/native-sdk/scripts/build-binaries.sh`
- `packages/native-sdk/npm/darwin-arm64/README.md`
- `docs/src/components/home/install-toggle.tsx`

---
title: "Installation"
description: "Install @native-sdk/cli, platform package layout, prerequisites, and success signals for a working native binary on each host OS."
---

`@native-sdk/cli` installs the `native` command as a Node dispatcher plus a per-platform prebuilt binary, and ships the SDK source your apps build against in the same package so `native init` and `native dev` work offline after install.

## Prerequisites

| Requirement | Notes |
| --- | --- |
| **Host OS** | macOS 11+, Linux, or Windows (desktop is the mature surface; mobile targets are experimental) |
| **Node.js + npm** | Required for the `native` wrapper (`bin/native.js`); the CLI binary itself is native |
| **Optional deps enabled** | Platform binaries ship as `optionalDependencies` — do not install with `--no-optional` / `--omit=optional` |
| **Zig 0.16.0+** | Not required before install; resolved on first `native dev\|build\|test` (see [Zig toolchain](#zig-toolchain)) |
| **Network (first build)** | macOS/Linux may download the pinned Zig archive from ziglang.org if none is on `PATH` |

Canvas-only apps need no system WebView SDK. System WebView / packaging checks are host-specific and surface under `native doctor`:

| Host | Extra checks when you need WebView or packaging |
| --- | --- |
| **macOS** | WKWebView (in-box); `codesign`, `xcrun notarytool`, `hdiutil` for signing/DMG |
| **Linux** | GTK4 + WebKitGTK 6.0 (`pkg-config --exists gtk4` / `webkitgtk-6.0`) for system WebView |
| **Windows** | WebView2 Evergreen runtime for system WebView |

## Install the CLI

<Tabs>
  <Tab title="Humans">
```bash
npm install -g @native-sdk/cli
```
  </Tab>
  <Tab title="Agents">
```bash
npx skills add vercel-labs/native
```
Installs the discovery skill so agents load version-matched guidance from the installed CLI via `native skills`. You still need `@native-sdk/cli` on the machine for `native` itself.
  </Tab>
</Tabs>

<Note>
Install runs **no package scripts**. The prebuilt `native` binary arrives as a platform optional dependency (`@native-sdk/cli-<platform>` with `preferUnplugged: true`). The main package carries SDK payload (`src/`, `build/`, `build.zig`, `build.zig.zon`, `app.zon`, `skills/`, `skill-data/`).
</Note>

Project-local install also works when you prefer not to global-install:

```bash
npm install @native-sdk/cli
npx native version
```

## How the install resolves a binary

```text
npm install -g @native-sdk/cli
        │
        ├─► @native-sdk/cli
        │     bin/native.js          # Node dispatcher (PATH entry: `native`)
        │     src/, build/, …        # SDK apps build against
        │     optionalDependencies   # all eight platform packages, exact version pin
        │
        └─► package manager installs exactly one matching:
              @native-sdk/cli-darwin-arm64
              @native-sdk/cli-darwin-x64
              @native-sdk/cli-linux-arm64-gnu | -musl
              @native-sdk/cli-linux-x64-gnu  | -musl
              @native-sdk/cli-win32-arm64
              @native-sdk/cli-win32-x64
                    └── bin/native[.exe]
```

`bin/native.js` picks the platform package, `require.resolve`s `bin/native` (or `bin/native.exe` on Windows), ensures execute bits on Unix, then `spawn`s the binary with your args.

| Step | Behavior |
| --- | --- |
| OS/arch map | `darwin` / `linux` / `win32` × `x64` / `arm64` |
| Linux libc | `ldd` / musl loader paths → `gnu` or `musl` package |
| Binary lookup | `require.resolve("@native-sdk/cli-<platform>/bin/native…")`, then legacy `bin/native-sdk-<platform>` for repo-local packs |
| SDK path | Sets `NATIVE_SDK_PATH` to the package root when `src/root.zig` is present (user override always wins) |

All packages share one version (currently **0.4.0**). `optionalDependencies` pins match that version so a given `@native-sdk/cli` always pairs with the binary built from the same release.

## Platform packages

Install `@native-sdk/cli` only. Do not install platform packages directly.

| Package | Target |
| --- | --- |
| `@native-sdk/cli-darwin-arm64` | macOS Apple silicon |
| `@native-sdk/cli-darwin-x64` | macOS Intel |
| `@native-sdk/cli-linux-arm64-gnu` | Linux arm64 (glibc) |
| `@native-sdk/cli-linux-x64-gnu` | Linux x64 (glibc) |
| `@native-sdk/cli-linux-arm64-musl` | Linux arm64 (musl) |
| `@native-sdk/cli-linux-x64-musl` | Linux x64 (musl) |
| `@native-sdk/cli-win32-x64` | Windows x64 |
| `@native-sdk/cli-win32-arm64` | Windows ARM |

Release engineering also stages flat GitHub assets (`native-sdk-<platform>[.exe]` + `CHECKSUMS.txt`) via `packages/native-sdk/scripts/build-binaries.sh` for non-npm distribution. See [Updates and package distribution](/updates-distribution).

## Zig toolchain

`native dev`, `native build`, and `native test` resolve Zig before building. Pin: **`0.16.0`** (`src/tooling/toolchain.zig`).

| Priority | Source |
| --- | --- |
| 1 | `NATIVE_SDK_ZIG` (trusted override) |
| 2 | `zig` on `PATH` when version is compatible (same major.minor, patch ≥ pin; pre-release/dev builds never match) |
| 3 | Managed install at `~/.native/toolchains/zig-0.16.0/zig` |
| 4 | Interactive download from ziglang.org (~55 MB, SHA-256 verified) into (3) |

```bash
# Non-interactive / CI: consent to managed download without a prompt
native dev --yes
native build --yes
native test --yes
```

<Warning>
Managed download covers **macOS and Linux** (aarch64/x86_64) only. On hosts without a pinned archive (including **Windows**), install Zig 0.16.0+ yourself or set `NATIVE_SDK_ZIG` to a compatible binary. Non-TTY sessions without Zig and without `--yes` refuse the download and print the recovery command.
</Warning>

Durable CLI state lives under `~/.native/` (toolchains). Per-app generated build graphs live under `<app>/.native/build/` (gitignored, disposable).

## Environment overrides

<ParamField body="NATIVE_SDK_PATH" type="string">
Absolute or relative path to an SDK/framework root that contains `src/root.zig`. Overrides the package-local SDK (npm wrapper sets this when the package carries source). Use a git checkout when developing against unreleased framework code.
</ParamField>

<ParamField body="NATIVE_SDK_ZIG" type="string">
Path to a Zig executable. Skips PATH and managed-toolchain resolution.
</ParamField>

<ParamField body="NATIVE_SDK_HOME" type="string">
Root for managed toolchains (`$NATIVE_SDK_HOME/toolchains` instead of `~/.native/toolchains`).
</ParamField>

## Verify the install

<Steps>
  <Step title="CLI binary resolves">
```bash
native version
```
Stdout payload (scripts parse this):

```text
native 0.4.0 (commit <hash>, automation protocol v<n>)
```

`version` / `--version` print version, build commit, and automation protocol on **stdout** so binary/framework skew is a one-command check.
  </Step>
  <Step title="Host diagnostics">
```bash
native doctor
```
Default mode is informational (exit 0). Use `--strict` when you want non-zero exit on warnings:

```bash
native doctor --strict
native doctor --manifest app.zon --strict
```
  </Step>
  <Step title="First app build (optional smoke)">
```bash
native init install_smoke
cd install_smoke
native dev --yes
```
Success: a native window opens with the scaffolded counter; subsequent runs are incremental. First compile builds the app against the SDK payload and may install Zig.
  </Step>
</Steps>

### Success signals by host

| Signal | macOS | Linux | Windows |
| --- | --- | --- | --- |
| `native version` prints `native <semver> (commit …)` | required | required | required |
| Platform optional package present | `cli-darwin-*` | `cli-linux-*-gnu` or `*-musl` | `cli-win32-*` |
| Zig for builds | PATH, managed `~/.native/toolchains`, or `NATIVE_SDK_ZIG` | same | PATH or `NATIVE_SDK_ZIG` (no managed download) |
| Pure canvas window (`native dev`) | Metal/AppKit host | software/real window host | Win32 host |
| System WebView doctor check | WKWebView available | WebKitGTK 6.0 + GTK4 | WebView2 registry client present |

## Troubleshooting

| Failure | Cause | Fix |
| --- | --- | --- |
| `No native binary found for <os>-<arch>` / `Expected package: @native-sdk/cli-…` | Optional deps omitted, or unsupported arch | Reinstall with optional deps: `npm install -g @native-sdk/cli` (not `--omit=optional`) |
| `Unsupported platform: <os>-<arch>` | Dispatcher only maps darwin/linux/win32 × x64/arm64 | Use a supported host |
| `Cannot make binary executable` | Permission/bit on Unix binary | `chmod +x` on the path printed by the error |
| Zig missing in non-TTY / CI | Managed download needs consent | Pass `--yes`, or install Zig 0.16.0 and ensure `zig version` is compatible |
| Wrong Zig on PATH | Incompatible major.minor or dev build | Install matching Zig, set `NATIVE_SDK_ZIG`, or let managed install take over on macOS/Linux |
| `cannot locate the Native SDK framework` | No `NATIVE_SDK_PATH` and binary cannot walk to `src/root.zig` | Reinstall `@native-sdk/cli` (must include SDK payload) or set `NATIVE_SDK_PATH` to a full checkout |
| Linux WebView doctor fails | Missing GTK4 / WebKitGTK 6 | Install distro packages (`libgtk-4-dev`, `libwebkitgtk-6.0-dev` or equivalents); not required for canvas-only apps |
| Windows WebView doctor fails | WebView2 runtime missing | Install Evergreen WebView2 Runtime |

## What install does *not* set up

- **CEF / Chromium** — optional; use `native cef install` when an app selects the Chromium web engine (see [Web engines](/web-engines)).
- **Owned `build.zig`** — zero-config apps need none; `native eject` is optional.
- **Agent skill files in the workspace** — `native init` does not copy skills; use `npx skills add` or `native skills get`.
- **Frontend node_modules** — only for `--frontend …` scaffolds; installed on first managed frontend run.

## Next

<CardGroup>
  <Card title="Quickstart" href="/quickstart">
    Scaffold with `native init`, run `native dev`, and verify the first live window.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    Full verb list: init, dev, check, test, build, doctor, skills, and failure modes.
  </Card>
  <Card title="Agent skills" href="/agent-skills">
    Discovery install and `native skills list|get` for version-matched agent guidance.
  </Card>
  <Card title="Updates and package distribution" href="/updates-distribution">
    Multi-platform npm layout, version sync, and binary release assets.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix across macOS, Linux, Windows, and experimental mobile.
  </Card>
  <Card title="Contributing" href="/contributing">
    Toolkit checkout prerequisites (Zig, Node, pnpm) when working on the repository itself.
  </Card>
</CardGroup>

---

## 03. Quickstart

> Create an app with native init, run native dev, edit .native markup and Zig Model/Msg/update, and verify the first live window.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/03-quickstart.md
- Generated: 2026-07-09T08:18:07.684Z

### Source Files

- `README.md`
- `docs/src/app/quick-start/layout.tsx`
- `examples/hello/build.zig`
- `examples/calculator/README.md`
- `src/tooling/dev.zig`

---
title: "Quickstart"
description: "Create an app with native init, run native dev, edit .native markup and Zig Model/Msg/update, and verify the first live window."
---

`native init` scaffolds a zero-config native-rendered app (`app.zon` + `src/` + `assets/`), and `native dev` builds a Debug binary, launches a real OS window, and arms markup hot reload for `src/app.native`. Logic stays in Zig as `Model`, `Msg`, and `update` on `native_sdk.UiApp`; the view is declarative markup that binds and dispatches but never mutates state.

## Prerequisites

| Requirement | Notes |
| --- | --- |
| Host OS | macOS 11+ (primary), Linux, or Windows |
| CLI | `@native-sdk/cli` on npm, binary name `native` |
| Zig | Optional on PATH; CLI resolves or offers the pinned toolchain (`0.16.0`) under `~/.native/toolchains/` |
| Display | A desktop session for `native dev` (headless machines use `native test` / `-Dplatform=null`) |

Install and confirm the binary:

```bash
npm install -g @native-sdk/cli
native version
```

Expected shape: `native 0.4.0 (commit …, automation protocol v…)`.

<Note>
The CLI records the framework path at init (the SDK tree shipped with the npm package, or a checkout that contains `src/root.zig`). Override with `native init … --framework <sdk path>` or set `NATIVE_SDK_PATH` when running verbs later.
</Note>

## Create an app

```bash
native init my_app
cd my_app
```

Flags:

| Flag | Values | Default | Effect |
| --- | --- | --- | --- |
| path | directory | `.` | Destination root for the scaffold |
| `--frontend` | `native`, `next`, `vite`, `react`, `svelte`, `vue` | `native` | Native markup app vs WebView frontend shell |
| `--framework` | filesystem path | resolved from the `native` binary | SDK root written into full scaffolds / used by the managed graph |
| `--full` | boolean | off | Own `build.zig` / `build.zig.zon`, editor settings, CI (native frontend only; web frontends always scaffold full) |

Success output:

```text
created Native SDK app at my_app (native)

Next steps:
  cd my_app
  native dev
```

### Slim scaffold (default, `--frontend native`)

No app-owned build files. Verbs synthesize the graph under `.native/build/` (gitignored) and install binaries to `zig-out/`.

:::files
my_app/
├── app.zon              # identity, shell window, permissions, security
├── assets/
│   └── icon.png         # packaging icon source
├── src/
│   ├── app.native       # entire UI (markup)
│   ├── main.zig         # Model, Msg, update, UiApp wiring
│   └── tests.zig        # full-loop headless UI tests
├── .gitignore           # .native/, zig-out/, .zig-cache/
└── README.md
:::

### Full scaffold (`native init my_app --full`)

Same app sources plus owned build and tooling:

| Extra path | Purpose |
| --- | --- |
| `build.zig` | Calls `native_sdk.addApp` |
| `build.zig.zon` | Path dependency on the framework |
| `.vscode/settings.json` | Associates `*.native` with HTML mode |
| `.github/workflows/ci.yml` | Null-platform test + Linux smoke pattern |

Web frontends (`--frontend next|vite|react|svelte|vue`) always write the expanded shape plus a `frontend/` package and wire `native dev` to the frontend server (see [Frontend projects](/frontend)).

## Run the first window

```bash
native dev
```

What runs:

1. Resolve Zig (`NATIVE_SDK_ZIG` → compatible PATH `zig` → managed `~/.native/toolchains/` → optional download with consent, or `--yes`).
2. If no `build.zig`, generate/refresh `.native/build/` against the framework root.
3. `zig build run -Doptimize=Debug` from the app root (or `--build-file` + `--prefix` for the managed graph).
4. Launch the app process group; Ctrl-C tears down the tree so automation-enabled orphans do not linger.

Console signal:

```text
native dev: building and running my-app (Debug) — hot reload arms in Debug builds
```

The first compile builds the app and the SDK; later runs are incremental. A window opens with the scaffolded counter (480×320, GPU surface canvas `main-canvas`).

<Check>
Click **+** / **−** / **Reset**. The center label and status bar update. That is live `Msg` dispatch through the engine, not a static mock.
</Check>

### Name and binary

`native init my_app` derives package identity in `app.zon` (for example `.name = "my-app"`). Release and install paths use that name:

```text
zig-out/bin/my-app
```

## Authoring surface

### Markup: `src/app.native`

Scaffold view (bindings and message tags only — no mutation):

```html
<column gap="12" padding="16">
  <row gap="8" cross="center">
    <text grow="1">Counter</text>
    <button size="sm" variant="ghost" on-press="reset">Reset</button>
  </row>
  <row gap="8" main="center" cross="center" grow="1">
    <button variant="secondary" on-press="decrement">-</button>
    <text>{count}</text>
    <button variant="primary" on-press="increment">+</button>
  </row>
  <status-bar>count: {count}</status-bar>
</column>
```

| Construct | Role |
| --- | --- |
| `{count}` | Read-only binding to `Model.count` |
| `on-press="increment"` | Dispatches `Msg` tag `increment` |
| layout attrs (`gap`, `padding`, `main`, `cross`, `grow`) | Flex layout on engine widgets |

### Logic: `src/main.zig`

Core loop written by the scaffold:

```zig
pub const Msg = union(enum) {
    increment,
    decrement,
    reset,
};

pub const Model = struct {
    count: i64 = 0,
};

pub fn update(model: *Model, msg: Msg) void {
    switch (msg) {
        .increment => model.count += 1,
        .decrement => model.count -= 1,
        .reset => model.count = 0,
    }
}
```

Wiring (same file):

- `pub const app_markup = @embedFile("app.native");`
- `const CounterApp = native_sdk.UiApp(Model, Msg);`
- `CounterApp.create(…, .{ .update = update, .markup = .{ .source = app_markup, .watch_path = "src/app.native", .io = init.io }, … })`
- `runner.runWithOptions(app_state.app(), …)`

`update` is the only place model state changes. Markup rebuilds from the model after each message.

```text
  pointer / keyboard / button
            │
            ▼
         Msg tag
            │
            ▼
   update(*Model, Msg)     ◄── only mutation site
            │
            ▼
   rebuild view from Model
            │
            ▼
   GPU surface / OS window
```

## Edit while it runs

With `native dev` (Debug) and `.markup.watch_path = "src/app.native"`:

1. Change a label or add a button in `src/app.native`.
2. Save. Within about two seconds the window repaints.
3. Model state (for example `count`) is preserved across reload.
4. Parse failures keep the last good view on screen and surface line/column diagnostics (`app_state.markup_diagnostic`).

<Warning>
Hot reload is a Debug-path feature. `native build` defaults to ReleaseFast and does not arm the watcher the same way. Pass an explicit `-Doptimize=…` to `native dev` only if you intentionally override the Debug default (reload will not arm in release-mode binaries).
</Warning>

Zig logic changes (`Model` / `Msg` / `update`) require a rebuild — stop and re-run `native dev` (or keep the process cycle short).

## Validate without a full GUI session

### `native check`

```bash
native check
# optional: native check --strict
```

Collects every `src/**/*.native`, validates markup, validates `app.zon`, and uses `zig-out/model-contract.zon` when present (produced by a successful `native test` / model-contract step) for binding and message-tag checks against real `Model`/`Msg`.

Success:

```text
checked 1 markup file and app.zon
```

(or `… against the model contract` when the contract artifact is fresh).

### `native test`

```bash
native test
```

Runs the scaffolded suite in `src/tests.zig`: builds the markup tree, finds **+** / **−** / **Reset** by text, dispatches `msgForPointer` like the runtime, asserts model values and stable widget ids — headless, no display server.

Success ends with a build summary and:

```text
native test: passed (test tally in the build summary above)
```

Null platform (CI / headless hosts):

```bash
native test -Dplatform=null
```

## Release binary

```bash
native build
```

Default optimize: ReleaseFast. Install location:

```text
native build: built zig-out/bin/my-app (ReleaseFast)
```

Package into a host app bundle with packaging tooling after the binary exists (see [Packaging](/packaging)).

## Own the build later

| Path | When |
| --- | --- |
| `native init … --full` | Want `build.zig` from day one |
| `native eject` | Zero-config app outgrew the managed graph; writes owned `build.zig` / `build.zig.zon` once, then verbs drive `zig build` without regenerating them |

After eject, `native dev|build|test` keep working against your files.

## Verification checklist

| Signal | How to confirm |
| --- | --- |
| CLI installed | `native version` prints version + commit |
| Scaffold created | `created Native SDK app at …` and `src/app.native` exists |
| Dev build | `native dev: building and running … (Debug)` |
| Live window | OS window titled from `display_name` / shell config; counter UI visible |
| Interaction | +/-/Reset change `{count}` and status bar |
| Hot reload | Edit `app.native` → repaint without losing count |
| Markup valid | `native check` → checked N markup files and `app.zon` |
| Tests green | `native test` → passed |
| Release artifact | `native build` → `zig-out/bin/<name>` |

## Common failures

| Symptom | Cause | Fix |
| --- | --- | --- |
| `no app.zon here` | Wrong cwd | `cd` to the app root or `native dev path/to/app` |
| Framework not found | CLI cannot locate SDK | Reinstall `@native-sdk/cli`, run a checkout-built `zig-out/bin/native`, set `NATIVE_SDK_PATH`, or re-init with `--framework` |
| Zig missing / wrong version | No compatible toolchain | Accept the managed download, pass `--yes`, or set `NATIVE_SDK_ZIG` |
| Hot reload never fires | Release optimize, or no `watch_path` | Use default `native dev` Debug; keep scaffold markup options |
| Test / check mismatch after UI edit | `tests.zig` still expects old button labels | Update tests or restore labels |
| `native init --help` must not scaffold | Flag discipline | `--help` exits 0 without writing files |

## Optional next shapes

| Goal | Command / example |
| --- | --- |
| Web frontend in a WebView | `native init my_web --frontend next` (or vite/react/svelte/vue) |
| Smallest in-repo zero-config app | `examples/habits` (`app.zon` + `src/`, same verb shape) |
| Precision markup + plain TEA update | `examples/calculator` (`native dev`, `native test -Dplatform=null`) |
| Agent skill packs | `native skills list` → [Agent skills](/agent-skills) |

`examples/hello` is a hand-wired WebView shell (`zig build run`), not the default `native init` counter path.

## Next

<CardGroup>
  <Card title="Installation" href="/installation">
    Platform packages, prerequisites, and binary success signals per host OS.
  </Card>
  <Card title="App model" href="/app-model">
    Model, Msg, update loop wiring, and hot reload boundaries.
  </Card>
  <Card title="Native UI markup" href="/native-ui">
    Elements, attributes, flex layout, bindings, and LSP diagnostics.
  </Card>
  <Card title="State and data flow" href="/state-data-flow">
    Derive-don't-store, dispatch paths, and effects as the side-effect channel.
  </Card>
  <Card title="Testing" href="/testing">
    Full-loop UI tests, headless truth drivers, and frame-level checks.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    init, dev, check, test, build, eject, doctor, and flag surface.
  </Card>
</CardGroup>

---

## 04. Agent skills

> Shipped skill packs for core authoring, Native UI, and automation, including how agents discover and apply them via the CLI.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/04-agent-skills.md
- Generated: 2026-07-09T08:16:43.980Z

### Source Files

- `skills/native-sdk/SKILL.md`
- `skill-data/core/SKILL.md`
- `skill-data/native-ui/SKILL.md`
- `skill-data/automation/SKILL.md`
- `docs/src/app/skills/layout.tsx`

---
title: "Agent skills"
description: "Shipped skill packs for core authoring, Native UI, and automation, including how agents discover and apply them via the CLI."
---

The `@native-sdk/cli` package ships version-matched agent skill packs as `SKILL.md` documents under `skills/` and `skill-data/`. `native skills list` and `native skills get` discover those files next to the installed CLI (or via `NATIVE_SDK_SKILLS_ROOT`), print them to stdout, and optionally append each skill’s `references/` and `templates/` trees with `--full`. Skill content is not copied by `native init`; agents and evals load it explicitly so guidance always matches the toolkit binary on PATH.

## Layout on disk

The CLI walks two package-root directories for any file named `SKILL.md`:

| Directory | Role |
| --- | --- |
| `skills/` | Installer-facing discovery stub (`native-sdk`), marked `hidden: true` so it is omitted from `skills list` and `skills get --all` |
| `skill-data/` | Full skill packs served to agents: `core`, `native-ui`, `automation` |

```text
@native-sdk/cli package root
├── skills/
│   └── native-sdk/SKILL.md          # discovery stub (hidden)
└── skill-data/
    ├── core/
    │   ├── SKILL.md
    │   └── references/
    │       ├── project-anatomy.md
    │       ├── app-model-runtime.md
    │       ├── frontend-assets.md
    │       ├── bridge-security-native-capabilities.md
    │       └── web-engines-packaging-debugging.md
    ├── native-ui/
    │   └── SKILL.md
    └── automation/
        └── SKILL.md
```

Both trees are included in the published npm package (`package.json` `files`: `skill-data`, `skills`). The binary resolves the package root by walking parents of the executable until either directory exists; override with `NATIVE_SDK_SKILLS_ROOT` when the binary and skill trees are not co-located.

### Frontmatter contract

Each `SKILL.md` starts with YAML frontmatter. The CLI parses only these keys:

| Field | Required | Behavior |
| --- | --- | --- |
| `name` | yes | Skill identifier for `skills get <name>` |
| `description` | no | Printed as the second column of `skills list` |
| `hidden` | no | `true` / `yes` → excluded from list and `--all` |
| `allowed-tools` | no | Agent-installer metadata (e.g. discovery stub); ignored by the CLI printer |

Missing `name` or invalid frontmatter skips the file during discovery. Skills sort alphabetically by name.

## Install and discovery

Two layers keep install thin and content versioned:

1. **One-time agent install** — register the discovery skill with a skills installer:

```bash
npx skills add vercel-labs/native
```

That installs the compact `native-sdk` entry point (the hidden stub). It orients the agent and directs it to load deep content from the installed CLI rather than embedding a full snapshot.

2. **Version-matched content** — after `@native-sdk/cli` is on PATH, agents (or humans) pull live packs:

```bash
native skills list
native skills get core
native skills get core --full
native skills get native-ui
native skills get automation
```

<Note>
Because deep guidance is always read from the CLI, the agent stays aligned with whatever toolkit version each project uses. Updating `@native-sdk/cli` updates skill text without re-running the skills installer.
</Note>

Skill packs are portable Markdown on disk. Any coding agent that can run shell commands and load a `SKILL.md` path works; nothing in the format assumes a particular model provider, hosted connector, or proprietary skill catalog beyond the optional one-time installer command above.

## CLI surface

### Commands

```bash
native skills list
native skills get <name> [--full]
native skills get --all [--full]
native skills --help
```

| Command | Output | Notes |
| --- | --- | --- |
| `skills list` | `name\tdescription` per visible skill | Hidden skills omitted |
| `skills get <name>` | Full `SKILL.md` body on stdout | Includes frontmatter |
| `skills get <name> --full` | `SKILL.md` plus every file under `references/` and `templates/` | Each supplementary file is separated with `---` and a `# references/…` or `# templates/…` heading |
| `skills get --all [--full]` | All non-hidden skills concatenated | Skills separated by `\n---\n\n` |
| unknown name | stderr `Unknown skill: …`, exit 1 | Does not list available names in the error |
| no package root | stderr `native skills: could not find skills/ or skill-data/ near the native-sdk executable`, exit 1 | Fix install layout or set `NATIVE_SDK_SKILLS_ROOT` |

### Environment

<ParamField body="NATIVE_SDK_SKILLS_ROOT" type="string">
Absolute path to a directory that contains `skills/` and/or `skill-data/`. Checked first, before walking parents of the executable path.
</ParamField>

### Deliver a skill into an agent workspace

`skills get` is stdout-only, so delivery is a redirect into wherever the agent loads skills:

```bash
mkdir -p .claude/skills/native-ui
native skills get native-ui > .claude/skills/native-ui/SKILL.md
```

For implementation work that needs core references:

```bash
mkdir -p .claude/skills/core
native skills get core --full > .claude/skills/core/SKILL.md
```

<Warning>
`native init` does **not** copy skills into the app. Delivery is always explicit so the workspace skill is exactly what the installed CLI serves.
</Warning>

The repository eval harness uses the same path: after `native init … --frontend native`, it runs `native skills get native-ui` and writes the stdout into `.claude/skills/native-ui/SKILL.md` before the agent-under-test runs.

## Shipped skill packs

| Name | Path | Hidden | `--full` extras | Load when |
| --- | --- | --- | --- | --- |
| `native-sdk` | `skills/native-sdk/` | yes | none | Installer discovery only; not listed by default |
| `core` | `skill-data/core/` | no | five `references/*.md` files | Orienting, scaffolding, `app.zon`, WebView shells, bridge, packaging, debug |
| `native-ui` | `skill-data/native-ui/` | no | none today | Authoring `.native` views, `Model`/`Msg`/`update`, `UiApp`, markup testing |
| `automation` | `skill-data/automation/` | no | none today | Live app inspection, `native automate *`, smoke tests, screenshots |

### `core`

Shared foundation for both native-rendered and WebView-shell apps:

- Mental model: `App`, `Runtime`, `WebViewSource`, `app.zon`, `src/main.zig` vs `src/runner.zig`
- Task router into `--full` references
- Frontend frameworks (`next`, `vite`, `react`, `svelte`, `vue`), bridge commands, security defaults
- Package / doctor / validate command checklist

`--full` appends, in walk order under `references/`:

| Reference | Use for |
| --- | --- |
| `project-anatomy.md` | Generated files, build steps, project shape |
| `app-model-runtime.md` | `App` / `Runtime` callbacks, embedding, tests |
| `frontend-assets.md` | Dev server, HMR, bundled assets |
| `bridge-security-native-capabilities.md` | Bridge handlers, permissions, windows, dialogs |
| `web-engines-packaging-debugging.md` | System WebView vs CEF, package, doctor, logs |

For native-rendered UI authoring, `core` explicitly routes agents to `native skills get native-ui`. For running-app inspection, it routes to `automation`.

### `native-ui`

Authoring surface for native-rendered apps: declarative `.native` markup plus Zig `Model` / `Msg` / `update` on `native_sdk.UiApp`. Covers:

- App wiring (`UiApp.create`, markup watch / compiled markup, web panes, status items)
- Element and attribute catalog, flex layout, identity keys, token attributes
- Expression grammar, binding resolution, derive-don’t-store model methods
- Message dispatch, text-field mirror pattern, effects channel (`spawn` / `fetch` / file / clipboard / timer / audio)
- Secondary windows, hidden titlebar chrome, images, templates/imports, markdown, charts
- `native markup check`, unit testing via `msgFor*`, and live verification through automation

Editors treat `.native` as HTML for highlighting; `native init --full` can write the VS Code association.

### `automation`

File-based IPC automation every Native SDK app can embed (native-rendered and WebView shells). Covers:

- Prerequisites: build/run with `-Dautomation=true`, pass `automation.Server` into `RuntimeOptions`
- Commands: `wait`, `assert`, `list`, `snapshot`, `reload`, `screenshot`, widget drive verbs, `bridge`, `profile`, tray actions
- Protocol dir `.zig-cache/native-sdk-automation/` (cwd-relative; CLI never creates it)
- What is in scope (widgets, bridge, readiness, deterministic `gpu_surface` screenshots) and out of scope (WebView DOM / WebView pixels)
- Smoke-test and CI patterns, protocol mismatch and stale-instance debugging

## Recommended agent workflow

```mermaid
flowchart TD
  install["npx skills add vercel-labs/native\n+ npm install -g @native-sdk/cli"]
  list["native skills list"]
  core["native skills get core\n(+ --full for implement)"]
  branch{Task shape}
  ui["native skills get native-ui"]
  auto["native skills get automation"]
  build["native check / test / build / dev"]
  verify["native automate wait\nautomate assert / snapshot / screenshot"]

  install --> list --> core --> branch
  branch -->|markup Model Msg UiApp| ui --> build
  branch -->|WebView bridge package| build
  branch -->|live window / smoke| auto --> verify
  build --> verify
```

<Steps>
  <Step title="Install once">
    Install the CLI (`npm install -g @native-sdk/cli`) and, for agent hosts that use a skills installer, run `npx skills add vercel-labs/native`. Confirm with `native version` and `native skills list` (expect `automation`, `core`, `native-ui`).
  </Step>
  <Step title="Orient with core">
    Run `native skills get core` before explaining or changing an app. For implementation, use `native skills get core --full` so project anatomy, runtime, frontend, bridge, and packaging references are in the same stream.
  </Step>
  <Step title="Load the task-specific pack">
    Native UI authoring → `native skills get native-ui`. Live verification → `native skills get automation`. Write stdout into the agent’s skills directory when the host only auto-loads on-disk skills.
  </Step>
  <Step title="Edit the correct layer">
    Follow skill ownership: `app.zon` for manifest/policy, `src/main.zig` for app behavior / Model-Msg-update, `src/runner.zig` for runtime wiring, `src/*.native` for markup views, `frontend/` for web shells.
  </Step>
  <Step title="Validate, then automate">
    Prefer `native check` / `native test` / headless unit paths first. For GUI confidence, build with `-Dautomation=true`, run the app, then `native automate wait` and `native automate assert` from the app cwd.
  </Step>
</Steps>

## Failure modes

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `could not find skills/ or skill-data/` | Broken package layout or binary moved without skill trees | Reinstall `@native-sdk/cli`, or set `NATIVE_SDK_SKILLS_ROOT` to the package root that contains those dirs |
| `Unknown skill: …` | Typo or requesting the hidden discovery name via list-only habits | Run `native skills list`; use `core`, `native-ui`, or `automation`. `native-sdk` exists but is hidden |
| Empty `skills list` | No parseable `SKILL.md` under the resolved root | Confirm frontmatter `name:` fields and that root points at a real package |
| Stale guidance vs runtime APIs | Workspace skill snapshot older than CLI | Re-run `native skills get <name>` (and `--full` if used) from the current binary |
| Automation commands fail after skill-guided build | App not automation-enabled or wrong cwd | Build with `-Dautomation=true`, launch from the app directory, re-read the automation skill |
| Agent invents layout / APIs | Skipped skills, relied on model memory | Skills themselves instruct agents to assume zero prior Native SDK knowledge and read the pack first |

## Constraints and design notes

- **Version coupling** — skill Markdown ships inside `@native-sdk/cli` next to the Zig framework sources. Skills and binary move together on npm.
- **No init embedding** — scaffolds stay skill-free; evals and agents inject skills deliberately.
- **Hidden discovery skill** — `native-sdk` is for installer catalogs (`allowed-tools` allows `native:*` and `npx @native-sdk/cli:*`). Day-to-day agent work uses the three visible packs.
- **`--full` is append-only** — supplementary files are concatenated after `SKILL.md`; only `core` currently ships `references/`. Empty `templates/` directories are skipped.
- **Not a model-provider lock-in** — packs are plain files + CLI stdout. Hosts may load them as Claude-style skills, generic system context, or custom catalogs.

## Related pages

<CardGroup>
  <Card title="CLI reference" href="/cli-reference">
    Full `native` command surface, including `skills`, `automate`, `check`, and `dev`.
  </Card>
  <Card title="Native UI markup" href="/native-ui">
    Product docs for the markup and UiApp surface the `native-ui` skill teaches.
  </Card>
  <Card title="Automation" href="/automation">
    Embedded automation server, snapshots, widget driving, and screenshots.
  </Card>
  <Card title="App model" href="/app-model">
    Model, Msg, update loop wiring shared by skill guidance and runtime.
  </Card>
  <Card title="Quickstart" href="/quickstart">
    Scaffold with `native init`, run `native dev`, and verify the first window.
  </Card>
  <Card title="Testing" href="/testing">
    Headless UI tests and when to escalate from unit paths to automation.
  </Card>
</CardGroup>

---

## 05. App model

> Model, Msg, and update loop wiring, hot reload boundaries, and how markup binds values without mutating state.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/05-app-model.md
- Generated: 2026-07-09T08:19:07.046Z

### Source Files

- `skill-data/core/references/app-model-runtime.md`
- `src/runtime/core.zig`
- `src/runtime/flow.zig`
- `src/runtime/api.zig`
- `build/app.zig`

---
title: "App model"
description: "Model, Msg, and update loop wiring, hot reload boundaries, and how markup binds values without mutating state."
---

`native_sdk.UiApp(Model, Msg)` (and `UiAppWithFeatures`) owns the declarative canvas loop: a heap-resident `Model`, a tagged-union `Msg`, exactly one of `update` / `update_fx`, and a view from either a Zig builder (`Options.view`) or runtime-parsed `.native` markup (`Options.markup`). It implements `native_sdk.App` via `app()`, drives install / rebuild / typed dispatch on `Runtime`, and keeps side effects on the effects channel — not in the view.

## Loop shape

| Piece | Type / surface | Role |
| --- | --- | --- |
| **Model** | Plain Zig struct | All app state. Fields used with `create` need defaults. |
| **Msg** | `union(enum)` | Every event that can change state (press, input, command, effect result, chrome, …). |
| **update** | `fn (*Model, Msg) void` **or** `update_fx` | Sole state mutation path. Exactly one of the two is set. |
| **View** | `view` and/or `markup` | Derives `canvas.Ui(Msg)` nodes from `*const Model`. Never mutates the model. |

Runtime owns window creation, GPU presentation, resize, pointer/keyboard dispatch through the tree handler table, timers, accessibility, and markup watch polling. Input hits a widget → bound `Msg` → `applyMsg` → rebuild → repaint.

```mermaid
flowchart LR
  subgraph inputs [Input and host]
    Ptr[Pointer / keyboard]
    Cmd[Menus / shortcuts / tray]
    Fx[Effect completions]
    Watch[Markup watch timer]
  end
  subgraph uiapp [UiApp]
    Disp["dispatch / drainEffects"]
    Upd["update / update_fx"]
    Model[(Model)]
    Build["buildViewNode"]
    Tree["Ui.Tree + handler table"]
  end
  subgraph runtime [Runtime]
    Layout[layout + reconcile]
    Present[GPU / pixels]
  end
  Ptr --> Disp
  Cmd --> Disp
  Fx --> Disp
  Watch --> Build
  Disp --> Upd --> Model
  Model --> Build --> Tree --> Layout --> Present
```

## Wiring

`Options` asserts at init:

- At least one of `view` or `markup`.
- Exactly one of `update` or `update_fx`.
- If `windows_fn` is set, `window_view` is required.
- If `UiAppFeatures.runtime_markup` is `false`, `markup` must be null.

Construction paths:

| API | Model lifecycle |
| --- | --- |
| `UiApp.init(allocator, model, options)` | Caller supplies the initial model value. |
| `UiApp.create(allocator, options)` | Allocates the multi-MB app on the heap; model starts as `.{}` (every field needs a default); assign boot state through the pointer, then `destroy`. |
| `initInPlace` | Builds everything except the model; assign `app.model` immediately after. |

`app()` returns `native_sdk.App` with `scene_fn`, `event_fn`, `stop_fn`, and `replay_fn`. Stop tears down the effects channel while the platform is still alive; main’s deferred `deinit` only frees app memory afterward.

```zig
const dev = @import("builtin").mode == .Debug;
const App = native_sdk.UiAppWithFeatures(Model, Msg, .{ .runtime_markup = dev });
const Compiled = canvas.CompiledMarkupView(Model, Msg, @embedFile("app.native"));

const app_state = try App.create(std.heap.page_allocator, .{
    .name = "my_app",
    .scene = shell_scene,           // one window, one gpu_surface
    .canvas_label = "main-canvas",  // must match the surface view label
    .update = update,
    .view = Compiled.build,
    .markup = if (dev) .{
        .source = @embedFile("app.native"),
        .watch_path = "src/app.native",
        .io = init.io,
    } else null,
});
defer app_state.destroy();
// optional boot: app_state.model.count = 0;
try runner.runWithOptions(app_state.app(), .{ ... }, init);
```

Live reference: `examples/ui-inbox` (markup + Zig model), `examples/notes` (`init_fx`, commands, tokens), `examples/split-collapse` (layout tweens / hybrid view).

### Common `Options` fields

<ParamField body="name" type="[]const u8" required>
App name for traces and automation snapshots.
</ParamField>

<ParamField body="scene" type="ShellConfig" required>
Shell windows and views; canvas apps use a `gpu_surface` whose label equals `canvas_label`.
</ParamField>

<ParamField body="canvas_label" type="[]const u8" required>
Primary canvas view label for install, rebuild, and input routing.
</ParamField>

<ParamField body="update" type="fn (*Model, Msg) void">
Pure update. Mutually exclusive with `update_fx`.
</ParamField>

<ParamField body="update_fx" type="fn (*Model, Msg, *Effects) void">
Effects-capable update; spawn/cancel only from update arms, never from the view.
</ParamField>

<ParamField body="init_fx" type="fn (*Model, *Effects) void">
Runs exactly once on the installing frame, after the effects channel is bound and before the first view build.
</ParamField>

<ParamField body="view" type="fn (*Ui, *const Model) Node">
Zig builder or `CompiledMarkupView(...).build`. Used alone in release, or until watched markup diverges when both `view` and `markup` are set.
</ParamField>

<ParamField body="markup" type="MarkupOptions">
Runtime parser/interpreter: `source`, optional `sources` import closure, optional `watch_path` + `io` for Debug hot reload.
</ParamField>

<ParamField body="on_command" type="fn (name) ?Msg">
Maps shell command events (menu, shortcut, tray, toolbar, bridge) into messages.
</ParamField>

<ParamField body="on_chrome" type="fn (WindowChrome) ?Msg">
Hidden-inset titlebar overlay geometry into the model (traffic-light insets, band height).
</ParamField>

<ParamField body="windows_fn" type="fn (*const Model, *WindowsScratch) []const WindowDescriptor">
Model-declared secondary windows; presence is visibility. Pair with `window_view`. Cap: `max_ui_windows`.
</ParamField>

## Dispatch and rebuild

`dispatch(runtime, window_id, msg)`:

1. Binds the effects channel.
2. Optional `sync` reads runtime-owned layout state into the model.
3. `applyMsg` runs `update` or `update_fx`.
4. If not yet installed, returns without rebuilding (appearance/chrome can land pre-frame).
5. Rebuilds the main canvas tree and secondary window slots.

Effect completions drain on `.effects_wake` and each presented frame: each queued result becomes a `Msg`, then a single rebuild if any ran. Input mapping goes through the retained tree (`msgForPointerClick`, `msgForTextEdit`, `msgForKeyboard`, `msgForScroll`, `msgForDismiss`, `msgForResize`, `msgForChange`, `msgForContextMenu`, …). Optimistic runtime echoes (slider value, dismiss hide, text clear) apply immediately; the model owns the value after the mapped `Msg` and the next rebuild is source of truth.

Rebuild uses a two-arena swap: the previous tree’s arena stays alive until the following rebuild so the handler table remains valid between events. Virtual lists may do a second build pass in the same rebuild when window coverage is under-guessed.

## Markup binds values; it never mutates state

`.native` files are compiled (comptime or runtime) into the same `canvas.Ui(Msg)` tree a hand-written `view` would build: structural ids, flex layout, typed handlers.

| Markup form | Meaning |
| --- | --- |
| `{count}`, `{t.title}`, `{draft}` | Read model (or loop-item) fields / methods into attributes or text. |
| `on-press="increment"` | Names a unit `Msg` variant. |
| `on-press="set_filter:{f}"` | Payload variant with bound expression. |
| `on-input="draft_edit"` | Text edits map to a `Msg` carrying `TextInputEvent`; the model applies them. |
| `on-toggle="toggle:{t.id}"` | Control change → message with id payload. |
| `selected="{f == filter}"`, `checked="{t.done}"` | Controlled display from model; engine state survives until the model asserts a different value. |
| `key="id"` on `<for>` | Stable structural identity across reorder/filter. |

Markup cannot assign to the model. Every mutation is a named `Msg` handled in `update`. Variants never bound from markup can declare `pub const view_unbound = .{"chrome_changed"}` so dead-state lint skips them.

```html
<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>
<for each="visible" key="id" as="t">
  <checkbox checked="{t.done}" on-toggle="toggle:{t.id}" label="Done" />
  <text grow="1">{t.title}</text>
</for>
```

## Hot reload boundaries

| Mode | Mechanism | What reloads | What is preserved |
| --- | --- | --- | --- |
| **Root markup watch** | `Options.markup.watch_path` + `io`; Debug + `runtime_markup` | Watched file and its import closure via timer poll | Model state; last good view on parse failure |
| **Fragment watch** | `fragment_watch` with `CompiledX.fragment("path")` | Registered fragment closures (budget `max_watched_fragments` = 16) | Same degrade family; revert-to-baseline restores compiled path |
| **Release / non-Debug** | Watch machinery compiles out | Nothing on disk | Comptime `CompiledMarkupView` only |

When both `view` and `markup` are set, the compiled view runs until the watched file first diverges from the embedded baseline; then the interpreter takes over. Failed parses keep the last good document, set `app_state.markup_diagnostic` (line, column, message), and avoid teaching the same bad save every poll (hash-gated). Successful reload rebuilds without resetting the model.

Zig logic (`Model`, `Msg`, `update`) is not hot-reloaded — only markup sources under watch. Mobile embed uses the compiled view; markup hot reload is desktop Debug.

## Dev vs release engines

```zig
const dev_markup_reload = builtin.mode == .Debug;
const App = native_sdk.UiAppWithFeatures(Model, Msg, .{
    .runtime_markup = dev_markup_reload,
});
```

| Build | View path | Parser in binary |
| --- | --- | --- |
| Debug with `markup` + `watch_path` | Runtime interpreter after first disk divergence | Yes |
| Release with `CompiledMarkupView.build` | Comptime-compiled builder | No (`runtime_markup = false`) |

Both engines produce the same structural ids and handler table so tests, automation, and goldens hold across modes. Hybrid roots compose fragments as ordinary children: `CompiledHeaderView.build(ui, model)` inside a Zig `rootView` (see `examples/calculator`, `examples/notes`).

## Effects and purity

`update` stays free of async I/O. With `update_fx` / `init_fx`, use `Effects` (`fx.spawn`, `fx.fetch`, `fx.readFile` / `writeFile`, `fx.startTimer`, `fx.cancel`, audio helpers). Completions re-enter as ordinary `Msg`s. Views never spawn effects. Boot work belongs in `init_fx` (once, pre-first paint), not a guarded `on_frame`. See [Runtime and effects](/runtime-effects) and [State and data flow](/state-data-flow).

## Rebuild identity and controlled state

- **Structural identity**: widget ids survive rebuilds, reorders, and hot reloads. List items use `key` (or `global-key` across containers). Unkeyed same-kind siblings use positional identity — an `<if>` that inserts earlier siblings can re-disambiguate trailing ones.
- **Source wins**: engine-retained scroll/caret/focus survive until the model asserts a different controlled value; then the model applies.
- **Windows**: `windows_fn` presence is visibility; user close dispatches `on_close`; keeping the window in the declared set re-creates it on the next rebuild.

## Lower-level `App` / `Runtime`

`UiApp` is a layer over `App` + `Runtime` (`src/runtime/api.zig`, `flow.zig`, `core.zig`). WebView-first apps return `native_sdk.App` with `source` / `source_fn` / lifecycle hooks without a Model/Msg loop (e.g. `examples/hello`). Embed hosts pump frames through the mobile C ABI (`build/app.zig` `addMobileLib`, `native_sdk_app_*` symbols). Custom lifecycle, imperative windows, or bridge-heavy shells use the runtime surface directly.

## Constraints and failure modes

| Constraint / signal | Behavior |
| --- | --- |
| `create` without Model field defaults | Comptime error naming the field. |
| Both or neither of `update` / `update_fx` | Assert at `assertOptions`. |
| Markup parse error under watch | Last good view kept; `markup_diagnostic` set. |
| Fragment count &gt; 16 | Extra fragments stay compiled-only (warning). |
| Pre-install `dispatch` | Model updates; no rebuild until first GPU frame install. |
| Platform stop | `stop_fn` deinit effects while services are live. |

## Next

<CardGroup>
  <Card title="State and data flow" href="/state-data-flow">
    Derive-don’t-store, message dispatch paths, text editing, and effects as the only side-effect channel.
  </Card>
  <Card title="Native UI markup" href="/native-ui">
    Elements, attributes, flex layout, bindings, expressions, and LSP support for `.native` views.
  </Card>
  <Card title="Runtime and effects" href="/runtime-effects">
    Effect kinds, cancellation, worker wakes, and frame limits behind `update_fx`.
  </Card>
  <Card title="Quickstart" href="/quickstart">
    `native init` / `native dev` scaffold: first Model, Msg, update, and live window.
  </Card>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    Shell scenes, multi-window reconciliation, and GPU surface presentation.
  </Card>
  <Card title="Embedded app" href="/embed">
    C embed ABI and host-driven frames for the same UiApp model on mobile.
  </Card>
</CardGroup>

---

## 06. Native UI markup

> Elements, attributes, flex layout, bindings, expressions, and editor/LSP support for .native views compiled into the binary.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/06-native-ui-markup.md
- Generated: 2026-07-09T08:16:54.234Z

### Source Files

- `skill-data/native-ui/SKILL.md`
- `editors/native-markup/README.md`
- `editors/native-markup/extension.js`
- `src/runtime/canvas_widget_runtime.zig`
- `docs/src/app/native-ui/layout.tsx`

---
title: "Native UI markup"
description: "Elements, attributes, flex layout, bindings, expressions, and editor/LSP support for .native views compiled into the binary."
---

`.native` files declare the entire canvas widget tree for a native-rendered app: elements, flex layout, value bindings, message dispatch, and structure tags. At build time the source is either interpreted at runtime (`canvas.MarkupView`) or compiled at comptime (`canvas.CompiledMarkupView` / `CompiledMarkupImports`) into the same `canvas.Ui(Msg)` nodes a hand-written Zig builder would emit—identical structural widget ids and the same typed handler table. Markup never mutates state; it only reads the `Model` and dispatches `Msg` values. Logic lives in Zig (`update` / `update_fx`).

```text
src/<view>.native  ──parse──►  MarkupDocument
       │                         │
       │              ┌──────────┴──────────┐
       │              ▼                     ▼
       │     MarkupView (dev/hot reload)   CompiledMarkupView (release)
       │              │                     │
       └──────────────┴─────────► Ui(Msg).Tree ──► canvas_widget_runtime reconcile
```

## App wiring

A native UI app is markup plus TEA-shaped Zig:

| Piece | Role |
| --- | --- |
| `src/<view>.native` | View: elements, layout, bindings, `on-*` dispatch |
| `Model` | Plain struct; only source-of-truth fields |
| `Msg` | Tagged union of events |
| `update` / `update_fx` | Sole place state changes |

Wire the view through `native_sdk.UiApp` / `UiAppWithFeatures`. Release builds prefer comptime compilation and gate the interpreter with `.runtime_markup`:

```zig
const dev = @import("builtin").mode == .Debug;
const App = native_sdk.UiAppWithFeatures(Model, Msg, .{ .runtime_markup = dev });
const CompiledView = canvas.CompiledMarkupView(Model, Msg, @embedFile("habits.native"));

// Options:
.view = CompiledView.build,
.markup = if (dev) .{
    .source = @embedFile("habits.native"),
    .watch_path = "src/habits.native",
    .io = init.io,
} else null,
```

<ParamField body="source" type="[]const u8" required>
Embedded root markup bytes. Baseline for the first build and for watch comparison.
</ParamField>

<ParamField body="sources" type="[]const SourceFile" optional>
Import closure for multi-file documents (same set as `CompiledMarkupImports`). Paths are relative to the root file's directory.
</ParamField>

<ParamField body="watch_path" type="?[]const u8" optional>
Dev-only disk path to poll. When the root or any resolved import changes, the next rebuild swaps the view while preserving model state and widget ids. Omit in release.
</ParamField>

<ParamField body="io" type="?std.Io" optional>
Required when `watch_path` is set so the runtime can read files on the watch timer.
</ParamField>

With both `.view` (compiled) and `.markup` (watched) in Debug, the compiled tree renders until the watched file first changes, then the interpreter hot-reloads. Parse failures keep the last good view and set `app_state.markup_diagnostic` (path, line, column, message). Hybrid Zig roots that embed compiled fragments register each with `Options.fragment_watch` via `CompiledView.fragment("src/header.native")`—only Debug carries path/baseline data.

Reference app: `examples/habits` (`habits.native` + `CompiledHabitsView` + optional `watch_path`).

## Elements

Markup tags lower to canvas widget kinds. Structure tags (`for`, `if`, `else`, `template`, `use`, `import`, `slot`) are not widgets.

| Markup | Widget role | Notes |
| --- | --- | --- |
| `row`, `column` | Flex containers | Main axis horizontal / vertical |
| `stack`, `panel`, `card` | Overlay containers | Children layer; `gap` is a validation error |
| `scroll` | Scroll view | Put multi-child content in an inner `column`/`row` |
| `list`, `grid` | List / grid | Vertical stack / cell grid; `columns` on `grid` only |
| `tabs`, `toggle-group`, `button-group`, `radio-group`, `breadcrumb`, `pagination` | Horizontal row containers | Tab strips, chips, radios |
| `table` → `table-row` → `table-cell` | Table | Strict nesting; cells are text leaves |
| `dropdown-menu` | Menu surface | Children are `menu-item`s; `anchor` floats against parent |
| `accordion` | Accordion | Header via `text`; children while `selected` |
| `alert`, `bubble` | Surfaces | `alert` title via `text`; bubble message is content, not `text=` |
| `dialog`, `drawer`, `sheet` | Modal surfaces | In-place; show with `<if>` |
| `resizable` | Resizable panel | Engine drag handle; `width` is initial |
| `split` | Two-pane splitter | Exactly two element children; engine divider; `value` + `on-resize` |
| `tree` | Disclosure tree | Rows with `role="treeitem"` join one roving focus set |
| `text`, `badge`, `tooltip` | Text leaves | `{}` interpolation; `wrap` / `overflow` / `size` on `text` |
| `text` → `span` | Inline runs | Weight, mono, italic, scale, underline, foreground |
| `button`, `toggle-button`, `list-item`, `menu-item`, `toggle`, `switch`, `select`, `avatar` | Text-bearing controls | Label is content; optional `icon=` on several |
| `checkbox`, `radio`, `slider`, `progress` | Value controls | Labels via `text=` / `label=`; content text is an error |
| `text-field`, `input`, `search-field`, `combobox`, `textarea` | Text entry | `on-input` / `on-submit`; search-field has built-in clear |
| `status-bar` | Status bar | Content only |
| `separator`, `spacer` | Divider / flex space | Separator axis follows parent flex direction |
| `skeleton`, `spinner` | Loading leaves | Size skeleton with `width`/`height` |
| `icon` | Vector icon | Built-in name, `app:<name>`, or one `{binding}` |
| `markdown` | Markdown subtree | `source="{binding}"` only |
| `stepper` → `step`, `timeline` → `timeline-item` | Pipeline composites | Stage track / ledger list |
| `chart` → `series` | Data chart | `values="{binding}"` to `[]const f32` |
| `context-menu` | Parent metadata | Direct child of pressable parent; not flow content |
| `input-group` → `textarea` + `input-group-actions` | Grouped composer | One border; group owns focus ring |

**Not expressible in markup** (Zig `canvas.Ui` only): free-form `image` widgets (pixels are runtime `ImageId`s), `data_grid`, `popover` / `menu_surface`, `segmented_control` (use `tabs`/`toggle-group`), windowed virtual lists (`ui.virtualWindow` / `ui.virtualList`), `on_double_press`, and dynamic chart series composition / `.band` series. Avatars do bind images: `image="{binding}"` to a model `u64` ImageId (`0` keeps initials).

## Flex layout and attributes

### Layout attributes

| Attribute | Applies to | Behavior |
| --- | --- | --- |
| `gap` | Flow containers; `split` divider thickness | Invalid on stacking kinds (`stack`/`panel`/`card`/`alert`/`bubble`/`dialog`/`drawer`/`sheet`/`resizable`) |
| `padding` | Most containers | Uniform inset |
| `grow` | Flex children | Share free space on the main axis |
| `width`, `height` | Sized elements | Definite size; content neither shrinks nor silently overflows |
| `min-width` | Flex children; split panes | Floor without a definite max; bounds split drag |
| `main` | Flex containers | `start` \| `center` \| `end` \| `space_between` |
| `cross` | Flex containers | `stretch` \| `start` \| `center` \| `end` |
| `wrap` | `text` only | `"true"` word-wraps; unset/`false` is single-line |
| `overflow` | `text` only | `ellipsis` (default) or `clip` |
| `text-alignment` | Text leaves, titles | `start` \| `center` \| `end` |
| `columns` | `grid` only | Fixed column count |
| `virtualized`, `virtual-item-extent` | `scroll`/`list`/`grid`/`table` | Layout-culls mounted nodes (not full windowed virtual lists) |
| `anchor`, `anchor-alignment`, `anchor-offset` | `dropdown-menu` | Float against parent; late z-pass; window-clipped |
| `overscroll` | `scroll` only | `none` (default pin) or `rubber_band` |
| `window-drag` | Any element | macOS hidden-titlebar window move surface |

Numbers are plain (`gap="12"`). Booleans are `true`/`false` or a binding. When children's minimum sizes exceed a container, debug builds log `zero_canvas_layout` with container, axis, and overflow pixels—flex overflow is never silent.

### Appearance and state

Token attributes take **token names only** (no raw colors, no bindings): `background`, `foreground`, `accent`, `accent-foreground`, `border-color`, `focus-ring`, `radius` (`sm`/`md`/`lg`/`xl`). Names resolve against live design tokens on every rebuild (`finalizeWithTokens`).

Other common attributes: `variant`, `size` (control scale `default`/`sm`/`lg`/`icon`; on `text` also typography `heading`/`display`), `disabled`, `checked`, `selected`, `value`, `placeholder`, `icon`, `autofocus`, `role`, `label`, `expanded`, `key`, `global-key`.

### Identity

| Attribute | Scope |
| --- | --- |
| `key` | Sibling-scoped identity |
| `global-key` | Parent-independent identity (items that move between containers) |

Unkeyed same-kind siblings use positional identity; insert/remove earlier siblings can re-disambiguate trailing ones and hop engine-owned state (carets, scroll). Prefer keys on list rows.

## Structure tags

```html
<for each="visible" key="id" as="h">
  <row global-key="{h.id}">…</row>
</for>
<else>
  <text>Nothing yet</text>
</else>

<if test="{hasItems}">…</if>
<else>…</else>
```

- `<for>`: one or more children; `key` names an item field; every node an item emits shares that identity.
- `<else>` after `</for>`: empty iterable state.
- `<else>` after `</if>`: must immediately follow; nest for multi-branch (no `else-if` tag).
- Templates: top-of-file `<template name="…" args="…">` plus `<use template="…">`; optional `<slot/>` and `<import src="components/….native"/>`.
- Imports are relative to the root view directory; component files are templates-only. Compile multi-file docs with `canvas.CompiledMarkupImports(Model, Msg, "root.native", &sources)` and pass the same set on `MarkupOptions.sources`.

## Bindings

A path like `{h.streak}` resolves left to right from the model or a `for`/template scope:

| Source | Resolution |
| --- | --- |
| Model field | Direct read |
| Zero-arg `pub fn` inside `Model` | Called like a field |
| Arena scalar `pub fn (*const Model, Allocator)` | Formats into the one-build arena |
| `for each` | Slice/array field, `pub const` array, `fn (*const Model) []const T`, or arena-returning filter fn |
| Enums | Tag name for display and equality |

Everything bindable must live **inside** `pub const Model = struct { … }`. File-scope helpers next to the struct are invisible to bindings. Bindings are zero-argument; parameterized queries become named model methods.

Attribute values take a literal **or exactly one** `{expression}`. Text content interpolates any number of expressions (`{open} open · {done} done`). Path-only by design: message tags/payloads, `for each` iterables, import paths.

<Warning>
Binding a `canvas.TextBuffer` field directly on `text=` is a teaching error. Bind a `pub fn` that returns `.text()`, apply edits in `update`, and clear the buffer when the source should win.
</Warning>

## Expressions

Shared evaluator in `ui_markup_expr.zig` for the interpreter, compiled engine, and `native markup check`. Expressions are pure and total: closed function library, fixed bounds, guaranteed termination.

**Operands:** binding paths, numbers (no exponents), `'single-quoted strings'`, `true`/`false`.

**Operators:** arithmetic `+ - * /` (numbers only; `/` always float), comparison `== != < <= > >=` (no chaining), boolean `and`/`or`/`not`, concatenation `++`.

**Closed functions (17):** `fixed`, `thousands`, `percent`, `date`, `time`, `datetime`, `upper`, `lower`, `trim`, `min`, `max`, `abs`, `round`, `floor`, `ceil`, `plural`, `pad`.

**Bounds:**

| Limit | Value |
| --- | --- |
| Bytes | 256 |
| Terms/nodes | 64 |
| Nesting depth | 16 |
| Call args | 4 |

Division by zero, integer overflow, and non-finite floats are loud errors. `now()` is rejected—clock reads are effects; keep a timestamp field updated by `update`/`fx` and format with `date`/`time`/`datetime`. Prefer model methods for reused derivation; keep one-off presentation math inline (`{percent(done / total)}`).

## Messages and events

| Attribute | Payload shape | Notes |
| --- | --- | --- |
| `on-press`, `on-toggle`, `on-change`, `on-submit`, `on-dismiss`, `on-hold` | `tag` or `tag:{payload}` | Tag must be a `Msg` variant; payload coerces (int, float, enum tag, string, bool) |
| `on-input` | `Msg` with `canvas.TextInputEvent` | Every text edit |
| `on-scroll` | `Msg` with `canvas.ScrollState` | `scroll` only; echo offset through `value` |
| `on-resize` | `Msg` with `f32` | `split` only; echo fraction through `value` |
| `on-reach-end` | Plain `Msg` | Infinite-fetch hysteresis near content end |

Press rule: click lands on the nearest pressable widget under the pointer. Binding `on-press`/`on-toggle` makes any element a hit target. Nested pressables resolve to the deepest; text fields, scrolls, and modals claim their own presses. Value/text handlers are rejected on layout/decoration elements.

Context menus: declare `<context-menu>` as a **direct child** of the pressable parent. Children are `menu-item`s and `separator`s (with `if`/`for` around items). Attribute-less; presents as OS menu where available, else anchored canvas surface.

## Widget budgets

Fixed per-view capacities in `src/runtime/canvas_limits.zig`. Overflow is loud (`error.WidgetNodeLimitReached`, `WidgetContextMenuLimitReached`, `WidgetAnchoredSurfaceLimitReached`, chart limits, …); production degrades to the previous frame with a teaching diagnostic. Automation snapshots report headroom (`widget_nodes=N/1024`).

| Budget | Cap |
| --- | --- |
| Retained widget nodes / semantics | 1024 |
| Widget text bytes | 64 KiB |
| Context-menu items (view-wide) | 512 |
| Anchored floating surfaces | 16 |
| Chart series / points | 64 / 16384 |
| Spans per paragraph | 32 |

Bound unbounded collections: `virtualized` containers for model-held hundreds of rows; windowed `ui.virtualList` (Zig only) for dataset-scale lists; model windows + `on-scroll` / `on-reach-end` for non-uniform content.

## Validate and check

```bash
native markup check src/view.native   # grammar/structure, expressions, a11y, font tofu
native check                          # + model-contract binding/Msg validation when contract is built
zig build model-contract              # refresh zig-out/model-contract.zon from Model/Msg
```

`native markup check` reports `file:line:column` teaching errors without a full app build: unknown elements/attributes, expression bounds, unnamed interactive controls, role misuse, and literal codepoints outside the bundled face. With a fresh model contract (from `native test` or `zig build model-contract`), `native check` verifies binding paths, iterables, `key` fields, message tags, payload types, and expression types against the real `Model`/`Msg`, with did-you-mean. A stale/missing contract degrades to grammar-only checking with a loud note. Opt update-only names out of the unused-state lint with `pub const view_unbound = .{ … };` on `Model` or `Msg`.

Build-time: `CompiledMarkupView` turns markup/binding mistakes into `@compileError` with line/column. Runtime: hot-reload parse failures preserve the last good tree.

## Editor and LSP support

Tooling lives under `editors/native-markup/`.

| Piece | What it does |
| --- | --- |
| TextMate grammar | Tags, attributes, comments, `{…}` bindings, `on-*`, structure tags |
| `native markup lsp` | Diagnostics (same parser/validator as `markup check`), completion for elements/attributes, hover docs |
| VS Code extension | Dependency-free LSP client in `extension.js` (no `vscode-languageclient`, no npm build) |

LSP **does not** type-check binding paths or `Msg` tags against your app—that needs Model/Msg and runs at app build / hot reload / `native check` with a model contract.

### VS Code

```bash
zig build   # produces zig-out/bin/native
ln -s /path/to/native-sdk/editors/native-markup \
  ~/.vscode/extensions/native-sdk.native-markup-0.1.0
```

Optional setting when `native` is not on `PATH`:

```json
{ "native-markup.serverPath": "/path/to/native-sdk/zig-out/bin/native" }
```

Remove any `"files.associations"` mapping `*.native` → `html` so the `native-markup` language id applies. Scaffold-only HTML association is a fallback when the extension is not installed.

### Helix / Neovim

Helix: language-server command `native` with args `["markup", "lsp"]`, file-types `["native"]`. Neovim 0.10+: `vim.lsp.start` with `cmd = { "native", "markup", "lsp" }` on filetype `native-markup`.

### Smoke test without an editor

```bash
python3 editors/native-markup/scripts/lsp-smoke.py zig-out/bin/native
```

## Testing markup views

Unit tests build the tree without a display:

```zig
var view = try canvas.MarkupView(Model, Msg).init(arena, markup_source);
var ui = canvas.Ui(Msg).init(arena);
const tree = try ui.finalize(try view.build(&ui, &model));
// tree.msgForPointer / msgForKeyboard / msgForResize / msgForDismiss / …
```

Rebuild after every dispatch before pressing again. Disabled controls yield `null` from pointer helpers. Live verification: `native build -Dautomation=true`, then `native automate wait` / `widget-click` / `assert` against snapshot ids that match the structural ids unit tests see.

## Related pages

<CardGroup>
  <Card title="App model" href="/app-model">
    Model, Msg, update loop, hot reload boundaries, and binding without mutation.
  </Card>
  <Card title="State and data flow" href="/state-data-flow">
    Derive-don't-store, message dispatch, text editing paths, and effects.
  </Card>
  <Card title="Theming" href="/theming">
    Design tokens, live re-resolution, and token-only restyles.
  </Card>
  <Card title="Components" href="/components">
    Built-in catalog, previews, eject workflows, and widget identity.
  </Card>
  <Card title="Quickstart" href="/quickstart">
    native init, native dev, first .native edit, and a live window.
  </Card>
  <Card title="Agent skills" href="/agent-skills">
    Shipped skill packs including native-ui authoring guidance.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    native markup check, native check, and related verbs.
  </Card>
  <Card title="Automation" href="/automation">
    Snapshots, widget driving, and agent command surface over the live tree.
  </Card>
</CardGroup>

---

## 07. State and data flow

> Derive-don't-store patterns, message dispatch, text editing paths, and effects as the only channel for side effects.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/07-state-and-data-flow.md
- Generated: 2026-07-09T08:17:54.085Z

### Source Files

- `src/runtime/flow.zig`
- `src/runtime/core.zig`
- `src/runtime/effects.zig`
- `docs/src/app/state/layout.tsx`
- `skill-data/core/references/app-model-runtime.md`

---
title: "State and data flow"
description: "Derive-don't-store patterns, message dispatch, text editing paths, and effects as the only channel for side effects."
---

Native SDK apps keep a single `Model` as source of truth. Markup and Zig views never mutate it: they bind values and emit `Msg` values. `UiApp(Model, Msg)` owns the loop—`syncModel` → `applyMsg` (`update` or `update_fx`) → rebuild every open window’s tree—so every interaction, effect completion, and shell command converges on the same path.

## Data-flow contract

| Stage | Owner | Role |
| --- | --- | --- |
| View build | Markup / `view` / `window_view` | Read `*const Model`, bind values, attach typed handlers |
| Input / command / effect drain | Runtime + `UiApp` | Resolve handlers → `Msg`, never patch the model directly |
| `dispatch` | `UiApp` | Optional `Options.sync`, then `applyMsg`, then rebuild |
| `update` / `update_fx` | App | Mutate `Model`; side effects only through `*Effects` when using `update_fx` |
| Effects workers | `Effects(Msg)` | Off-thread I/O; post completions; model stays loop-thread only |

```mermaid
flowchart TB
  subgraph view_layer ["View (read-only)"]
    Markup[".native / CompiledMarkupView"]
    ZigView["view / window_view"]
  end
  subgraph ui_app ["UiApp loop"]
    Dispatch["dispatch"]
    Sync["syncModel + Options.sync"]
    Apply["applyMsg → update / update_fx"]
    Rebuild["rebuild + rebuildWindowSlots"]
  end
  subgraph effects ["Effects channel"]
    Fx["fx.spawn / fetch / file / clipboard / timer / audio"]
    Workers["worker threads"]
    Queue["bounded MPSC + wake"]
    Drain["drainEffects → takeMsg"]
  end
  Model[("Model")]
  Markup --> Dispatch
  ZigView --> Dispatch
  Dispatch --> Sync --> Apply --> Model
  Apply --> Rebuild
  Rebuild --> Markup
  Rebuild --> ZigView
  Apply --> Fx
  Fx --> Workers --> Queue --> Drain --> Apply
```

Markup cannot mutate state. A press, toggle, edit, scroll, or menu item only names a `Msg` tag (and optional payload). Logic lives in Zig `update`.

## Derive, don't store

The model holds **sources of truth only**: raw items, filters, draft text, effect keys, open flags. Counts, sums, filtered lists, and formatted display strings are **methods or arena-backed functions** evaluated at view build—not fields maintained in every `update` arm.

```zig
// Wrong: cached derivable, hand-maintained in update
visible_count: usize,
summary_storage: [64]u8,

// Right: sources + pure methods
pub fn visibleCount(model: *const Model) usize { ... }
pub fn visibleCents(model: *const Model) u64 { ... }
```

Derived numbers need no allocation; bind methods and compose in markup:

```html
<status-bar>{habit_count} habits · {totalDays} total days</status-bar>
```

### Format at view time

Store money as integer cents (or other source units). Format into the **build arena** inside an allocator-taking model method—the arena lasts exactly one view build:

```zig
pub fn visible(model: *const Model, arena: std.mem.Allocator) []const VisibleExpense {
    // filter + allocPrint into arena; return slice for for each="visible"
}

pub fn summary(model: *const Model, arena: std.mem.Allocator) []const u8 {
    return std.fmt.allocPrint(arena, "{d} expenses · {s} total", .{
        model.visibleCount(), formatCents(arena, model.visibleCents()),
    }) catch "";
}
```

Bindings and `for each` resolve **only Model decls** (fields and `pub fn` methods inside the struct). A file-scope helper next to `Model` is invisible to markup.

| Binding form | Resolves to |
| --- | --- |
| `{habit_count}` | Struct field |
| `{totalDays}` | `pub fn totalDays(m: *const Model) usize` |
| `{summary}` | `pub fn summary(m: *const Model, arena: Allocator) []const u8` |
| `for each="visible"` | Slice/array field, pub const slice, or `(*const Model)` / `(*const Model, Allocator)` method |
| Enum tags | Render as name; `set_filter:{f}` coerces back into enum payload |

For `<if test>`, prefer an explicit `bool` method (`test="{hasHabits}"`) over numeric truthiness.

<Note>
Parameterized queries (cards of column X) stay zero-argument bindings: one named model function per case, or a markup template arg—not free-form call expressions in markup.
</Note>

## Message dispatch

### Entry points → `Msg`

| Source | Mechanism |
| --- | --- |
| Pointer / keyboard on widgets | Tree handler table (`msgForPointer`, `msgForKeyboard`, …) |
| Markup `on-press`, `on-toggle`, `on-submit`, … | Tag or `tag:{payload}` coerced to `Msg` variant |
| `on-input` | `Msg` variant payload **must** be `canvas.TextInputEvent` |
| `on-scroll` | Payload `canvas.ScrollState` (offset, extents) |
| `on-resize` (split) | Payload `f32` fraction already applied by the runtime |
| Shell menus / shortcuts / tray | `Options.on_command` maps command name → `?Msg` |
| App-level unclaimed keys | `Options.on_key` → `?Msg` |
| Effect completions | Stored constructors (`lineMsg`, `exitMsg`, `responseMsg`, …) |

### `dispatch` sequence

`UiApp.dispatch` is the single apply+rebuild funnel:

1. Bind the effects channel to the live runtime.
2. **`syncModel`** — if `Options.sync` is set, call it with the current layout so sliders / similar runtime-owned values land in the model before `update`.
3. **`applyMsg`** — `update_fx(model, msg, &effects)` or `update(model, msg)`.
4. Publish audio snapshot fields for automation.
5. If installed: **rebuild** main canvas and every secondary window slot (one model generation across windows).

Handler and update errors **degrade** in production: recorded in a bounded dispatch-error ring (`max_dispatch_errors = 16`), visible in traces and automation snapshots; the app keeps running. Test harnesses may set `DispatchErrorPolicy.propagate` so capacity errors fail tests.

### Event attributes (markup)

| Attribute | Payload shape | Notes |
| --- | --- | --- |
| `on-press` / `on-toggle` / `on-hold` / `on-dismiss` | Plain tag or `tag:{payload}` | Nested pressables: deepest wins |
| `on-submit` | Tag / payload | Enter on field; primary+Enter on textarea; primary action on list rows with submit |
| `on-input` | `TextInputEvent` only | Teaching error if payload type mismatches |
| `on-scroll` | `ScrollState` | Echo offset through `value` |
| `on-resize` | `f32` | Split divider; store and echo through `value` |
| `on-reach-end` | Plain `Msg` | Infinite-fetch; hysteresis 1.0 / 1.5 viewports |
| `on-change` (markup slider) | Plain `Msg` (no value) | Use `Options.sync` or Zig `on_value` for the applied f32 |

Press rule: plain text/icons fall through to the nearest pressable ancestor; editable fields, scrolls, and modal surfaces claim their own hits.

## Text editing paths

### Elm-style mirror (`TextBuffer`)

The model applies every edit and owns the text. The runtime keeps caret/selection while the bound source text matches; a source-side clear (e.g. submit) wins.

```html
<text-field text="{draft}" placeholder="New task…" on-input="draft_edit" on-submit="add" grow="1" />
```

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

Bind the **function** (`text="{draft}"`), never a `TextBuffer` field—the validator treats direct buffer binding as a teaching error. Live reference: `examples/ui-inbox`.

### Runtime clipboard and selection

- Editable text: cmd/ctrl+C/X/V is runtime-owned. Cut/paste arrive as ordinary `TextInputEvent`s (`insert_text`, etc.); the mirror stays consistent with no extra code.
- Paste over capacity sets `edit_truncated` on the keyboard event and `TextBuffer.truncated` on the model side.
- Static text leaves support drag-select + copy per widget; selection survives rebuilds while text bytes are unchanged.
- Programmatic clipboard from `update`: `fx.writeClipboard` / `fx.readClipboard` on the effects channel—not `pbcopy` / `xclip`.

`canvas.TextBuffer(capacity).apply` routes through `applyTextInputEvent` (insert, delete, word nav, clear, composition). Payload lifetimes for effect lines match text events: **copy what you keep** before the next drain.

## Echo runtime-applied values

Some values are applied by the runtime first, then delivered on a `Msg`. Store them and **echo the same value** through the element’s binding so reconcile treats the source as unchanged and rebuilds never fight live interaction.

| Control | Msg payload | Echo through |
| --- | --- | --- |
| `split` | `f32` fraction (`on-resize`) | `value="{sidebar_split}"` |
| `scroll` | `ScrollState` (`on-scroll`) | `value` offset |
| Markup `slider` `on-change` | Plain tag | `Options.sync` (or Zig `on_value`) |

```zig
.sidebar_resized => |fraction| model.sidebar_split = fraction,
```

Windowed virtual lists are the exception: the runtime re-derives the visible window; do **not** echo offsets into `virtualList`’s `value`.

## Effects: only channel for side effects

Anything that leaves the process—subprocesses, HTTP, files, clipboard, timers, audio—goes through `Effects(Msg)`. Views never spawn. Set **exactly one** of `update` and `update_fx` (asserted at init).

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

pub fn update(model: *Model, msg: Msg, fx: *Effects) void { ... }
// options: .update_fx = update,
```

### Boot effects

`Options.init_fx` runs **once** on the installing frame, after the channel is bound and **before** the first view build. Prefer it for open-with-fetch. Do not unguardedly spawn from `on_frame` (it re-fires every frame).

### Channel surface

| API | Terminal / stream Msgs | Notes |
| --- | --- | --- |
| `fx.spawn` | `on_line` + always one `on_exit` | `.lines` (default) or `.collect` whole stdout |
| `fx.fetch` | one `on_response`; optional `on_line` if `.stream` | Shared key space and slots with spawn |
| `fx.writeFile` / `fx.readFile` | one `on_result` | Over-bound writes **rejected** (never partial) |
| `fx.writeClipboard` / `fx.readClipboard` | one `on_result` | Sync on loop thread; result still a Msg |
| `fx.startTimer` / `fx.cancelTimer` | `on_fire` | Separate table (`max_effect_timers = 16`) and key namespace |
| `fx.playAudio` + transport | `on_event` | Separate audio key namespace; one player |
| `fx.registerImage*` / `unregisterImage` | (sync, no Msg) | Image registry bound on the channel |

Keys are caller-chosen `u64`s stored in the model—no handles. `fx.cancel(key)` guarantees no further stream lines and exactly one terminal with `.cancelled` / cancelled outcome.

### Hard limits (loud, never silent)

| Limit | Default |
| --- | --- |
| In-flight effects (spawn/fetch/file/clipboard) | `max_effects = 16` |
| Completion queue depth | `max_effect_queue_entries = 64` |
| Line bytes (default / ceiling) | 4 KiB / 256 KiB |
| Collect stdout | 512 KiB |
| Fetch body | 256 KiB |
| File path / bytes | 1 KiB / 1 MiB |
| Clipboard | platform max (effects use same bound) |

Overflow surfaces as `.rejected` terminals, `truncated` / `dropped_before` / `dropped_lines` flags—not silent drops without accounting.

### Drain path

Workers never touch the model. They post fixed-size records, call `PlatformServices.wake_fn`, and the loop thread runs `drainEffects`: `while (effects.takeMsg()) applyMsg` then one rebuild. Host-pumped embeds also drain on presented frames. In tests: set `effects.executor = .fake`, `feedLine` / `feedExit` / `feedResponse`, then `runtime.dispatchPlatformEvent(app, .wake)`.

Drain-scratch rule: `EffectLine.line`, `EffectResponse.body`, file/clipboard result bytes, and collect `output` / `stderr_tail` die after the current `update` call—**copy into the model**.

Live patterns: `examples/effects-probe`; evals judge derive-don't-store + effect keys on process-monitor style apps.

## Time as a testable seam

`update` does not receive `std.Io`. Use the runtime clock facade—not raw `clock_gettime`:

```zig
native_sdk.nowMs()                 // wall ms
native_sdk.monotonicMs()           // durations
// Prefer model-owned seam for testable logic:
clock: native_sdk.Clock = .system,
// tests: TestClock.advanceMs / setWallMs
```

Wall time for “what time is it?”; monotonic for “how long?”. Do not subtract wall timestamps for durations. Markup `date()` / `time()` take model-held unix seconds—`now()` in expressions is a teaching error (clock reads are effects).

## Async state in the model

In-flight work is ordinary model state: `loading: bool`, effect keys, line rings, status enums driven by exit/response outcomes. The UI binds those fields/methods; it never observes worker threads. Cancel is model-driven (`fx.cancel(key)` from a cancel `Msg`).

## Verification

| Check | Signal |
| --- | --- |
| Unit dispatch | Build markup/Zig tree → `msgFor*` → `update` → rebuild → assert model + stable ids |
| Effects | Fake executor + `.wake` drain; assert pending requests and applied Msgs |
| Live UI | `native build -Dautomation=true` + `native automate assert` / snapshot greps |
| Binding contract | `native check` / model-contract after `native test` |
| Teaching errors | Direct `TextBuffer` bind, wrong `on-input` payload, file-scope “model” fns |

## Related pages

<CardGroup>
  <Card title="App model" href="/app-model">
    Model, Msg, UiApp options, hot reload boundaries, and markup vs compiled views.
  </Card>
  <Card title="Native UI markup" href="/native-ui">
    Elements, attributes, bindings, expressions, and message attribute grammar.
  </Card>
  <Card title="Runtime and effects" href="/runtime-effects">
    Effect kinds, cancellation, worker wakes, capacities, and frame limits.
  </Card>
  <Card title="Capabilities" href="/capabilities">
    Guarded OS services and effect-channel access boundaries.
  </Card>
  <Card title="Testing" href="/testing">
    Full-loop UI tests, effect tests, and headless truth drivers.
  </Card>
  <Card title="Commands and keyboard shortcuts" href="/commands-shortcuts">
    Single command routing into on_command and Msg.
  </Card>
</CardGroup>

---

## 08. Theming

> Design tokens for color, radius, and typography, live theme re-resolution, chrome passes, and token-only restyles across apps.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/08-theming.md
- Generated: 2026-07-09T08:19:17.368Z

### Source Files

- `src/primitives/canvas/themes/geist.zig`
- `docs/src/app/theming/layout.tsx`
- `examples/deck/src/chrome.zig`
- `examples/soundboard/README.md`
- `examples/deck/README.md`

---
title: "Theming"
description: "Design tokens for color, radius, and typography, live theme re-resolution, chrome passes, and token-only restyles across apps."
---

Every built-in control reads its colors, radii, type rungs, control heights, state washes, and focus geometry from one `DesignTokens` value resolved by `UiApp.effectiveTokens()` on each rebuild. There are no per-component style props and no emitter special cases: retheme the tokens and the whole widget register moves together. Packs, sparse overrides, full custom registers, markup token refs, and optional chrome display-list passes all hang off that same surface.

## Architecture

```mermaid
flowchart TB
  subgraph sources [Theme inputs]
    AppZon["app.zon theme"]
    ThemeOpt["ThemeOptions\npack / scheme / contrast\ndensity / reduce_motion"]
    Overrides["DesignTokenOverrides"]
    Appearance["OS Appearance\nscheme · high_contrast · reduce_motion"]
    Model["Model via tokens_fn"]
  end

  subgraph resolve [Resolution]
    Pack["DesignTokens.theme\nhouse | geist"]
    Apply["withOverrides"]
    Effective["UiApp.effectiveTokens\nstamps pixel_snap.scale"]
  end

  subgraph consumers [Consumers]
    Finalize["Ui.finalizeWithTokens\nstyle_tokens → WidgetStyle"]
    Emitters["Widget emitters\nControlTokens + ColorTokens"]
    Chrome["UiApp.chrome\nprefix · widgets · suffix"]
  end

  AppZon --> ThemeOpt
  Appearance --> Model
  Appearance --> Effective
  ThemeOpt --> Pack
  Pack --> Apply
  Overrides --> Apply
  Model --> Effective
  Apply --> Effective
  Effective --> Finalize
  Effective --> Emitters
  Effective --> Chrome
```

| Layer | Owner | Role |
| --- | --- | --- |
| Pack register | `ThemePack` + `DesignTokens.theme` | Full palette, control tables, metrics, type scale per scheme/contrast |
| Overrides | `DesignTokenOverrides` | Sparse brand deltas; null fields keep the base |
| App tokens | `Options.tokens` / `tokens_fn` | Fixed look or model-owned look |
| Runtime stamp | `effectiveTokens` | Always writes live `pixel_snap.scale` after app resolution |
| Markup refs | `StyleTokenRefs` / `finalizeWithTokens` | Named color and radius fields re-resolve on every rebuild |
| Chrome | `ChromeOptions` | Fixed-count display-list prefix/suffix around widgets |

## Theme packs

Built-in packs are complete design systems selectable by name. Pack selection is manifest/API vocabulary (`app.zon` `theme`, `ThemeOptions.pack`, `UiApp.Options.theme`), never markup.

| Pack | Default | Register |
| --- | --- | --- |
| `house` | Yes | Monochrome neutral scale in `tokens.zig` defaults: near-black filled controls on light, porcelain on dark; semantic hues only where they mean something |
| `geist` | No | Cool neutrals over pure white/black pages; monochrome primaries; blue focus and info; 6px control corners / 12px surfaces; 32/40/48 control ladder; segmented spinner |

Declare the pack in `app.zon`:

```zig
.{
    .id = "com.example.app",
    .name = "example",
    .version = "0.1.0",
    .theme = "geist",
}
```

Unknown names fail at compile time via `app_runner.manifestThemePack()` — valid names are `house` and `geist`, never a silent fallback. Scaffold wiring:

```zig
.theme = app_runner.manifestThemePack(),
```

Apps that own tokens pick the pack themselves:

```zig
return canvas.DesignTokens.theme(.{
    .color_scheme = scheme,
    .pack = .geist,
    .contrast = if (high_contrast) .high else .standard,
    .reduce_motion = reduce_motion,
});
```

### Pack comparison (control geometry)

| Token | House default | Geist |
| --- | --- | --- |
| Control heights sm / default / lg | 28 / 32 / 36 | 32 / 40 / 48 |
| Button insets sm / default / lg | (house defaults) | 6 / 10 / 14 |
| Radius sm–lg / xl | 6 / 8 / 10 / 14 | 6 / 6 / 6 / 12 |
| Focus | Mid-gray ring | Blue ring, 2px + 2px offset |
| Spinner | House arc | Segmented dial (12 pills, 1200ms) |
| Primary fill light | Near-black `#171717` | Pure black `#000000` |

## ThemeOptions axes

`ThemeOptions` composes every live axis a pack honors:

| Field | Type | Default | Effect |
| --- | --- | --- | --- |
| `color_scheme` | `ColorScheme` | `.light` | Light or dark palette |
| `contrast` | `ColorContrast` | `.standard` | Standard or high-contrast register |
| `density` | `Density` | `.regular` | `compact` / `regular` / `spacious` metric scaling |
| `reduce_motion` | `bool` | `false` | When true, motion durations become `MotionTokens.reduced()` |
| `pack` | `ThemePack` | `.house` | Which built-in register resolves first |

Resolution order inside `DesignTokens.theme` / `themeWithOverrides`:

1. Pack register for scheme + contrast (`house` tables or `themes/geist.zig`)
2. Reduce-motion motion zeroing and density assignment
3. Sparse `DesignTokenOverrides` (if any)
4. Runtime-stamped `pixel_snap.scale` and platform text measurement (not themable)

## Wiring tokens into UiApp

`UiApp.Options` exposes three related fields:

| Field | When to use |
| --- | --- |
| `theme: ThemePack` | Stock tokens only (no `tokens` / `tokens_fn`): pack + system appearance |
| `tokens: ?DesignTokens` | Fixed design system; does not re-derive on OS flips unless you rebuild options |
| `tokens_fn: ?*const fn (*const Model) DesignTokens` | Model-owned theming; consulted every install/rebuild |

`effectiveTokens()` priority:

1. `tokens_fn(model)` then stamp `pixel_snap.scale`
2. Else static `tokens`
3. Else stock `DesignTokens.theme` from tracked system appearance + `Options.theme`

Default behavior: an app that sets neither `tokens` nor `tokens_fn` follows the OS light/dark, high-contrast, and reduce-motion settings live without a restart.

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

// Model-owned (soundboard / deck pattern)
.tokens_fn = tokensFromModel,
.on_appearance = onAppearance,

fn onAppearance(appearance: native_sdk.Appearance) ?Msg {
    return .{ .set_appearance = appearance };
}

pub fn tokensFromModel(model: *const Model) canvas.DesignTokens {
    return theme.tokens(
        model.colorScheme(),
        model.appearance.high_contrast,
        model.appearance.reduce_motion,
    );
}
```

Register custom faces before the first build when typography points at app fonts:

```zig
.fonts = &[_]MyApp.FontRegistration{
    .{ .name = "pixel", .ttf = @embedFile("fonts/MyFace.ttf") },
},
// tokens set typography.font_id / mono_font_id to registered ids
```

## Live theme re-resolution

Theme changes do not require markup rewrites or widget forks.

| Signal | Path | Result |
| --- | --- | --- |
| OS scheme / contrast / motion | Platform appearance → runtime (`followsSystemAppearance`) or `on_appearance` → model → `tokens_fn` | Next rebuild gets new `DesignTokens` |
| Model brand flag | Msg updates model; `tokens_fn` branches packs or overrides | Token-only restyle of the same tree |
| Markup style attrs | `background="surface"`, `radius="md"`, series colors | Stored as `StyleTokenRefs`; `finalizeWithTokens(effectiveTokens())` resolves names against the live palette |
| Explicit Zig `style` | Set on the widget | Wins over a token ref on the same field |

Constraints:

- Style token attributes take **literal** token names only (compile/validation error otherwise). Dynamic styling stays in Zig.
- Numeric type sizes on controls are refused; retheme `TypographyTokens` to move the scale.
- Token refs re-resolve every rebuild; raw colors embedded in views do not.

Color token names are the fields of `ColorTokens` (`background`, `surface`, `text`, `accent`, `destructive`, …). Radius names are `sm` / `md` / `lg` / `xl`.

```xml
<column gap="8" background="surface" radius="md">
  <text foreground="text_muted">Secondary copy</text>
  <badge background="accent" foreground="accent_text">Live</badge>
</column>
```

Charts take the same color token vocabulary on series so both schemes stay honest.

## Token groups

`DesignTokens` groups (all overridable via matching `*Overrides` structs):

| Group | Contents |
| --- | --- |
| `colors` | Page/surface washes, text inks, border, accent pair, destructive/success/warning/info + knockout inks, focus ring, shadow ink, scrim, disabled |
| `controls` | Per-control visual tables (`button_primary`, `input`, `slider`, `search_field`, `panel`, …): background/hover/active/pressed/disabled, foreground, border, radius, stroke; null fields fall through to palette + state formulas |
| `states` | Interaction formulas when a control table is silent (hover/pressed alpha cuts, disabled wash, selection washes) |
| `metrics` | Control height ladder, button insets/label steps, icon extents, slider track/thumb, tabs indicator thickness/gap, button-group gap, spinner geometry |
| `typography` | `font_id` / `mono_font_id`, families, body/label/title/button/heading/display sizes |
| `radius` | Corner scale sm–xl |
| `stroke` | Hairline, regular, focus width + focus offset |
| `spacing` | xs–xl |
| `shadow` / `blur` / `motion` / `scroll` / `layer` / `pixel_snap` | Elevation, backdrop blur, durations/easing, scroll physics (including overscroll defaults), z-bands, snapping |
| `density` | Compact / regular / spacious |

### Overrides layer

`DesignTokenOverrides` is a sparse overlay: every left-null field keeps the base, so brand layers survive pack updates.

```zig
var tokens = canvas.DesignTokens.themeWithOverrides(.{
    .color_scheme = scheme,
    .pack = .geist,
}, .{
    .colors = .{
        .accent = canvas.Color.rgb8(223, 38, 112),
        .accent_text = canvas.Color.rgb8(255, 255, 255),
        .focus_ring = canvas.Color.rgb8(223, 38, 112),
    },
    .controls = .{
        .slider = .{ .active_background = canvas.Color.rgb8(223, 38, 112) },
    },
    .radius = .{ .sm = 4, .md = 4, .lg = 4 },
});
```

Some control tables state their own hues (for example house/geist slider `active_background`). Moving only `colors.accent` does not always recolor them — restated entries need a matching controls override.

## Chrome display-list pass

Non-widget decoration (chassis fills, bevels, gradients, segment readouts) uses `UiApp.Options.chrome: ChromeOptions`, not widget trees.

| Field | Meaning |
| --- | --- |
| `prefix_commands` | Command count drawn **behind** widgets |
| `suffix_commands` | Command count drawn **in front of** widgets |
| `build` | `fn (model, *Builder, size, tokens) !void` emitting exactly `prefix + suffix` commands in that order |

Install path: chrome build → append prefix → `layout.emitDisplayList` → append suffix → `setCanvasDisplayList` + `emitCanvasWidgetDisplayListWithChrome`. A mismatched count returns `error.InvalidChromeCommandCount`.

Rules that keep chrome honest under tests:

- Fixed command counts across model states: hide by drawing offscreen, not by skipping commands.
- Path points and gradient stops must stay alive until the runtime deep-copies the list (file-scope storage for computed paths).
- Chrome receives the same `DesignTokens` as widgets; high-contrast paths typically drop decoration and fall back to `tokens.colors.*`.

Deck is the reference extreme: fixed 512×264 window, enamel prefix + glass/bevel/readout suffix, layout table shared with the widget views so machining cannot drift from controls.

## App patterns

### Token-only brand (soundboard)

- Pack: `geist` (also stated in `app.zon` as `.theme = "geist"`).
- One brand decision: pink accent via `DesignTokenOverrides` on `colors.accent` / `accent_text` / `focus_ring` and `controls.slider.active_background`.
- Follows system scheme live through `on_appearance` + `tokens_fn`.
- High contrast: **skips** brand overrides and returns the pack’s loud register untouched (accessibility beats brand).
- Reduce motion: `ThemeOptions.reduce_motion` zeroes motion tokens.

### Full custom skin (deck)

- Does **not** follow OS color scheme; one hardware finish.
- Starts from light house base, then replaces `colors`, radii, typography (registered Geist Pixel on both font slots), metrics, and per-control plating.
- Material mapping reuses semantic slots deliberately (for example `background` = smoked glass, `accent` = live phosphor).
- High contrast abandons the skin for the framework high-contrast light palette and stock faces; chrome keeps command counts but strips grain/glare/scanlines.
- Chrome pass supplies 3D hardware; tokens only state fills and inks widgets need.

| Concern | Soundboard | Deck |
| --- | --- | --- |
| Pack | Geist + pink overrides | Custom register (not a pack restyle) |
| OS scheme | Live | Ignored for color |
| Typography | Pack defaults | App-registered pixel face, grid sizes |
| Chrome | None (widgets + markup) | Full prefix/suffix machining |
| High contrast | Pack HC, no brand | Framework HC light + stripped chrome |

## Authoring a full design system

1. Start from published values (neutral scale, accent, semantics, type rungs, radii, heights), not screenshots.
2. Fill `colors` for all four scheme/contrast pairs (high contrast is not optional).
3. Put per-control exceptions only where the system diverges from its own semantic palette.
4. Restate `states` / `metrics` only when interaction feedback or the size ladder differs.
5. Hand the result through `tokens_fn` (or static `tokens`). Never cache `pixel_snap.scale` or platform text measurement — the runtime stamps them after your function.
6. Pin a reference surface signature in tests; re-bless only after reviewing captures.

```zig
pub fn brandTokens(scheme: canvas.ColorScheme, contrast: canvas.ColorContrast) canvas.DesignTokens {
    return .{
        .colors = brandColors(scheme, contrast),
        .controls = brandControls(scheme, contrast),
        .typography = .{ .body_size = 14, .title_size = 20 },
        .radius = .{ .sm = 4, .md = 6, .lg = 8, .xl = 12 },
        .metrics = .{ .control_height = 36 },
    };
}
```

## Theme, eject, or build

When a built-in is wrong for the product, order of operations:

1. **Theme it** — Engine controls are themed, never forked. Packs, overrides, control tables, and full registers cover every visual decision.
2. **Eject it** — Library composites can become app code via `native eject component <name>` when shape ownership is required (see Components).
3. **Build it** — Compose from the same primitives; new composites still read `DesignTokens`.

Tokens restyle the register; they do not restructure control semantics, states, or layout contracts. That boundary is what makes pinned pixel signatures meaningful across packs.

## Verification

| Check | How |
| --- | --- |
| Pack name valid | `native check` / `manifestThemePack()` compile error lists `house`, `geist` |
| Theme follows OS | Flip system appearance; unthemed or `tokens_fn`+`on_appearance` apps re-ink without restart |
| Brand overrides | Assert resolved `colors.accent` (and any controls that hard-code hue) under both schemes |
| High contrast | Confirm brand layer drops when required; chrome counts unchanged if chrome is used |
| Chrome counts | Rebuild chrome across model states; runtime rejects wrong prefix+suffix totals |
| Markup tokens | Unknown style token names fail validation/compile |
| Design-system pin | Render reference surface under pack/app tokens; pin pixel signature in CI (SDK does this for house and geist catalogs) |

Example suite commands from the music examples:

```sh
native test -Dplatform=null
```

## Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| Dark mode does nothing | Static `tokens` set without scheme branching; or `tokens_fn` ignores model appearance |
| Accent moved but slider/focus kept old hue | Control table states its own color; override `controls.*` and `focus_ring` |
| High contrast looks branded | Overrides applied on the HC path; gate brand on `!high_contrast` |
| Chrome panics / install fails | Emitted command count ≠ `prefix_commands + suffix_commands` |
| First frame wrong face | Font registration after first build; put faces in `Options.fonts` |
| Markup color does not flip | Used raw style color instead of a token name |
| Unknown `app.zon` theme | Typo; only `house` and `geist` resolve |

## Constraints

- Pack choice is not a markup attribute.
- Style token attributes are literals; no bindings.
- Runtime owns surface scale after app token resolution.
- Chrome is optional; when present, counts are part of the contract.
- Accessibility policies (high contrast, reduce motion) are first-class theme axes, not afterthoughts.

## Related pages

<CardGroup>
  <Card title="Native UI markup" href="/native-ui">
    Style token attributes, size rungs, and how markup binds without owning theme state.
  </Card>
  <Card title="App model" href="/app-model">
    Model/Msg wiring, rebuild boundaries, and where tokens_fn sits in the loop.
  </Card>
  <Card title="Components" href="/components">
    Catalog inventory, eject workflows, and when theming ends and composition begins.
  </Card>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    Chromeless shells, titlebar styles, and host presentation that chrome skins sit on.
  </Card>
  <Card title="Testing" href="/testing">
    Headless truth drivers and frame-level checks for token and chrome pins.
  </Card>
</CardGroup>

---

## 09. Components

> Built-in component catalog inventory, preview generation, eject workflows, and identity constraints for reusable widgets.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/09-components.md
- Generated: 2026-07-09T08:18:06.599Z

### Source Files

- `docs/src/lib/components-pages.ts`
- `docs/src/lib/component-vocab.json`
- `src/tooling/eject_components.zig`
- `docs/src/components/component-preview.tsx`
- `docs/src/components/eject-section.tsx`

---
title: "Components"
description: "Built-in component catalog inventory, preview generation, eject workflows, and identity constraints for reusable widgets."
---

The Native SDK ships a retained-canvas component catalog: each built-in is an engine-drawn widget (not a platform control skin), expressible as a `.native` markup element and as a `canvas.Ui` builder call. Docs inventory, attribute tables, static light/dark tiles, and the live wasm hover preview all bind to one generated vocabulary (`docs/src/lib/component-vocab.json`) and one scene catalog (`tools/docs_preview_scenes.zig`). Library **composites** that are honest compositions of public primitives can transfer into the app with `native eject component <name>`; engine control classes cannot.

## Ownership model

Three moves, in preference order:

| Move | When | Mechanism |
| --- | --- | --- |
| **Theme** | Restyle color, radius, type, control metrics, state washes | Design tokens / theme packs — see [Theming](/theming) |
| **Eject** | Own a composite’s **shape** (structure, order, extras) | `native eject component <name>` → `src/components/` once |
| **Build** | No library shape fits | Markup `<template>` / `<import>` / `<use>` / `<slot/>`, or a Zig view function |

Engine controls (buttons, text fields, tabs, and similar) are **not** ejectable: their behavior lives in the runtime. Change them with tokens, not a fork.

```text
  App markup / Zig view
          │
          ▼
  ┌───────────────────────┐     theme tokens      ┌──────────────────┐
  │ Built-in elements     │◄──────────────────────│ DesignTokens     │
  │ canvas.Ui builders    │                       └──────────────────┘
  └───────────┬───────────┘
              │ eject (composites only)
              ▼
  src/components/<name>.{native,zig}
  (app-owned; second eject → AlreadyEjected)
```

## Catalog inventory

Sidebar, index grid, titles, and OG metadata share one ordered list: `componentPages` in `docs/src/lib/components-pages.ts` (45 pages today). Each entry has `slug`, display `name`, preview tile stem (`preview` → `/components/<preview>-{light,dark}.webp`), and a one-line `blurb`.

| Slug | Preview stem | Blurb |
| --- | --- | --- |
| `accordion` | `accordion-hero` | Disclosure surface with a model-owned open state |
| `alert` | `alert-hero` | Inline callouts with icon and variant color |
| `avatar` | `avatar-hero` | Initials fallback and runtime-registered images |
| `badge` | `badge-hero` | Status labels in every variant |
| `breadcrumb` | `breadcrumb-hero` | Hierarchy trail with separators |
| `bubble` | `bubble-hero` | Chat-message surfaces for either side |
| `button` | `button-hero` | Variants, sizes, inline icons, and states |
| `button-group` | `button-group-hero` | Attached action buttons as one segmented bar |
| `card` | `card-hero` | The bordered, elevated surface container |
| `chart` | `chart-hero` | Line, bar, and band series (Zig builder) |
| `checkbox` | `checkbox-hero` | Binary choice with model-owned state |
| `combobox` | `combobox-hero` | Text entry with an anchored suggestions menu |
| `dialog` | `dialog-hero` | Modal surface with model-owned dismissal |
| `drawer` | `drawer-hero` | Side-anchored modal surface |
| `dropdown-menu` | `dropdown-menu-hero` | Anchored floating menus and menu items |
| `icon` | `icon-hero` | The built-in vector icon registry |
| `input` | `input-hero` | Single-line text entry: input, text field, search field |
| `input-group` | `input-group-hero` | One bordered field: textarea plus accessory actions |
| `list` | `list-hero` | Rows with icons, selection, and virtualization |
| `markdown` | `markdown-hero` | GFM rendering through native widgets |
| `pagination` | `pagination-hero` | Page navigation row |
| `panel` | `panel-hero` | The plain surface container |
| `progress` | `progress-hero` | Determinate progress bar |
| `radio` | `radio-hero` | Single choice within a radio group |
| `resizable` | `resizable-hero` | Panel with an engine-managed drag handle |
| `scroll` | `scroll-hero` | Scroll regions with model-observable offsets |
| `select` | `select-hero` | Trigger plus the anchored dropdown options pattern |
| `separator` | `separator-hero` | Hairline rules, horizontal and vertical |
| `sheet` | `sheet-hero` | Bottom-anchored modal surface |
| `skeleton` | `skeleton-hero` | Loading placeholders that sketch the content |
| `slider` | `slider-hero` | Continuous value control |
| `spacer` | `spacer-hero` | Flexible empty space between siblings |
| `spinner` | `spinner-hero` | Indeterminate progress leaf |
| `split` | `split-hero` | Draggable two-pane splitter |
| `status-bar` | `status-bar-hero` | Window-bottom status text |
| `stepper` | `stepper-hero` | Stage progress with completed/active/pending steps |
| `switch` | `switch-hero` | On/off switches with model-owned state |
| `table` | `table-hero` | Rows and cells with hairline dividers |
| `tabs` | `tabs-hero` | Tab strip over segmented controls |
| `textarea` | `textarea-hero` | Multi-line text entry |
| `timeline` | `timeline-hero` | Ledger list with indicators and connectors |
| `toggle` | `toggle-hero` | Pressed-state toggles, toggle buttons, and groups |
| `tooltip` | `tooltip-hero` | The floating label above the control it annotates |
| `tree` | `tree-hero` | Disclosure tree with one roving focus set |
| `virtual-list` | `virtual-list-hero` | Windowed rows: the view builds only what's visible |

Runtime catalog surface (engine): `native_sdk.canvas.builtin_component_kinds`, `builtin_component_names`, and `builtinComponentWidget(...)` construct each component’s default foundation (style, root widget kind, semantic role, composite flag).

### Markup element vocabulary

`component-vocab.json` is regenerated by `zig build docs-component-previews` (not hand-edited for docs tables). It currently exposes on the order of **65** markup elements plus structure tags `for`, `if`, `else`, `template`, `use`, `import`, and `slot`. Representative families:

| Family | Element names (examples) |
| --- | --- |
| Layout | `row`, `column`, `stack`, `panel`, `card`, `scroll`, `list`, `grid`, `split`, `tree`, `spacer`, `separator` |
| Text / chrome | `text`, `badge`, `status-bar`, `tooltip`, `span` |
| Controls | `button`, `checkbox`, `radio`, `toggle`, `switch`, `toggle-button`, `slider`, `progress`, `text-field`, `search-field`, `textarea`, `input`, `select`, `combobox` |
| Grouping | `button-group`, `radio-group`, `toggle-group`, `tabs`, `breadcrumb`, `pagination`, `input-group`, `input-group-actions` |
| Surfaces | `accordion`, `alert`, `bubble`, `dialog`, `drawer`, `sheet`, `dropdown-menu`, `context-menu`, `resizable` |
| Data / media | `table` / `table-row` / `table-cell`, `avatar`, `icon`, `markdown`, `chart` / `series`, `skeleton`, `spinner` |
| Composites | `stepper` / `step`, `timeline` / `timeline-item`, `reactions` (bubble child) |

Full attribute semantics, flex layout, bindings, and expressions live under [Native UI markup](/native-ui). Attribute tables on component docs resolve names against this vocab at docs build time and throw if a name is missing (regenerate previews).

## Dual authoring surface

Every catalog component is intended to be expressible two ways:

**Markup** (declarative, hot-reloadable in `native dev`):

```html
<stepper active="{publish_step}">
  <step>Draft</step>
  <step>Review</step>
  <step>Publish</step>
</stepper>
```

**Zig builder** (when the closed markup grammar is not enough — image ids, per-cell templates, floating surfaces, or structure that needs typed messages):

```zig
ui.stepper(.{ .active = model.publish_step }, &.{
    .{ .label = "Draft" },
    .{ .label = "Review" },
    .{ .label = "Publish" },
});
```

State remains model-owned: bindings and message handlers wire through the TEA loop; markup does not mutate state in place. See [App model](/app-model) and [State and data flow](/state-data-flow).

## Preview generation

### Static tiles (docs site)

```bash
zig build docs-component-previews
```

| Output | Path / shape |
| --- | --- |
| WebP pairs | `docs/public/components/<scene>-{light,dark}.webp` |
| Icon gallery | `docs/public/components/icons/<icon>-{light,dark}.webp` |
| Vocab JSON | `docs/src/lib/component-vocab.json` (elements, attributes, tokens, `previews` dimensions, `ejectable`) |

Implementation: `tools/docs_component_previews.zig` drives each scene from `tools/docs_preview_scenes.zig` through the **deterministic reference renderer** (offscreen `TestHarness`, fixed layout/emit, no platform fonts). PNG cache lands under `/tmp/native-sdk-component-previews`; conversion uses `cwebp` lossless (stable bytes per cwebp release). Light and dark schemes use `DesignTokens.theme(.{ .color_scheme = ... })`. Hover states are optional per scene via pointer_move into a target widget frame.

Index cards use the `*-hero` stem from `componentPages.preview`. Per-page `<ComponentPreview name="..." />` looks up `vocab.previews[name]` for width/height and fails the docs build on unknown names.

### Live wasm upgrade

```bash
zig build docs-wasm-preview
# → docs/public/wasm/component-preview.wasm
```

In the browser, `ComponentPreview` starts as the static light/dark pair and upgrades on hover/click/focus to the **same scene catalog** compiled to `wasm32-freestanding`. The host pumps `gpu_surface_input`, reads pixels from the CPU reference renderer, and follows the site theme. Effects do not run in the preview host; interactivity is engine control state plus each scene’s mini-model (same dispatch shape as a UiApp, scaled to the catalog).

```mermaid
flowchart LR
  subgraph scenes [docs_preview_scenes.zig]
    S[Scene build + model]
  end
  subgraph static [docs-component-previews]
    R[TestHarness + layout + emit]
    W[cwebp light/dark WebP]
    V[component-vocab.json]
  end
  subgraph live [docs-wasm-preview]
    M[component-preview.wasm]
    C[Browser canvas / rAF]
  end
  S --> R --> W
  S --> M --> C
  R --> V
  V --> Docs[ComponentPreview / AttrTable / EjectSection]
```

### Docs integrity constraints

- Unknown preview name in MDX → build error: regenerate with `zig build docs-component-previews`.
- Unknown ejectable name in `<EjectSection>` → build error against `vocab.ejectable` (same rows as the CLI registry).
- `previews` map dimensions must match rendered scenes so tiles do not ship with wrong aspect boxes.

## Eject workflow

### CLI

```bash
native eject component <name> [dir]
```

| Item | Detail |
| --- | --- |
| **Requires** | `app.zon` in the app directory (or pass `[dir]`) |
| **Registry** | `src/tooling/eject_components.zig` → `components` table |
| **Creates** | Parent `src/components/` if needed; writes embedded canonical source |
| **Once** | Destination already present → `AlreadyEjected` (“delete it to re-eject”) |
| **Typos** | Edit distance ≤ 2 against ejectable names; else lists all ejectable names |
| **Success** | Prints path + form; header in the file documents call-site migration; run `native check` |

Not the same verb as plain `native eject` (build-graph ownership of `build.zig` / `build.zig.zon`). `eject component` is dispatched first so `component` is never treated as a directory.

### Ejectable set (current)

| Name | Form | Destination |
| --- | --- | --- |
| `stepper` | Zig view function | `src/components/stepper.zig` |
| `timeline` | Markup template | `src/components/timeline.native` |
| `timeline-item` | Zig view function | `src/components/timeline_item.zig` |

Growing the set is three steps: add canonical source under `src/tooling/components/`, add identity tests, add a row to `eject_components.components`. Canonical sources are `@embedFile`’d into the CLI binary.

### After eject: migrate call sites

Eject transfers **ownership**, not a new element name. Markup templates keep working only through `<use>`; the built-in element still means the library form at unmigrated sites.

**Timeline container** (template):

```html
<!-- before -->
<timeline gap="4" label="Activity">...</timeline>

<!-- after -->
<import src="components/timeline.native"/>
...
<use template="timeline" gap="4" label="Activity">...</use>
```

`timeline-item` children still flow into the template’s `<slot/>`. Zig ejects import as ordinary Zig modules and call `build` / typed helpers as documented in each file header.

Every ejected file opens with an ownership header containing `Ejected from the Native SDK component library` and the restoring command `native eject component ...` (enforced by unit tests).

### Docs mirror

Component MDX pages that are ejectable render `<EjectSection components={[...]} />`, which resolves each name against `vocab.ejectable` and prints the matching `native eject component …` lines so docs never advertise an eject the CLI refuses.

## Identity constraints

Identity tests (`src/tooling/components/identity_tests.zig`, suite step `zig build test-eject-components`, also under `zig build test`) prove:

1. **Tree equality** — For shared inputs, the ejected form and the library form produce identical widget trees: every id, field, and handler (`expectEqualDeep` on root and handlers).
2. **Behavior** — Press handlers and other msg bindings fire the same payloads (for example timeline-item `on_press`).
3. **Markup templates** — Ejected `<use template="timeline">` (via import of the embedded template) matches the built-in `<timeline>` element tree for the same children; ids hash as if the body were written in place.
4. **Write-once** — `eject` succeeds once and returns `AlreadyEjected` on a second write with unchanged contents.

Consequence: ejecting is never a visual or behavioral change at the moment of transfer—only an ownership change. A library refactor that drifts a composite’s tree fails identity tests until the canonical source is updated.

Widget identity rules that affect all components (not only eject):

- Template expansion inlines bodies so **ids hash as handwritten markup** (no identity jump when extracting a template).
- `key` is sibling-scoped; `global-key` survives reparenting.
- Automation and accessibility targets depend on stable structure and labels—forked composites must preserve or deliberately change those contracts.

## App-authored components

When building new shapes:

| Form | File convention | Reach |
| --- | --- | --- |
| Markup component file | Templates only under markup root (tooling convention: `src/components/*.native`) | `<import src="…"/>` then `<use template="…">` |
| Zig view function | Ordinary Zig module | Call from any Zig view; compose with markup windows as needed |

Rules that matter in practice:

- Style through **token references** (`background="surface"`, `style_tokens = .{ .background = .surface }`), not hardcoded colors—components inherit app retheme for free.
- `native markup check` / `native check` walk the import closure; component files also check standalone.
- Runtime and compiled engines share one embedded `SourceFile` set for markup imports; add each component file once for production and tests.
- Markup grammar is closed: image ids, per-cell render callbacks, and some floating surfaces remain Zig-side (see [Native UI markup](/native-ui)).

## Commands and verification

| Goal | Command / signal |
| --- | --- |
| Regenerate docs tiles + vocab | `zig build docs-component-previews` → webp count log + vocab path |
| Regenerate live preview wasm | `zig build docs-wasm-preview` |
| Eject a composite | `native eject component stepper` (cwd with `app.zon`) |
| Validate app after eject | `native check` |
| Identity suite | `zig build test-eject-components` or `zig build test` |
| Docs build guard | Unknown `ComponentPreview` / `EjectSection` / `AttrTable` name fails the Next/docs build |

### Failure modes

| Symptom | Cause | Action |
| --- | --- | --- |
| `unknown component "…"` | Name not in eject registry | Use `stepper`, `timeline`, or `timeline-item`; heed did-you-mean |
| `already ejected at …` | Destination file exists | Edit the app copy, or delete to re-eject a fresh library snapshot |
| `no app.zon here` | Wrong directory | Run inside the app root or pass `[dir]` |
| Docs: unknown preview / attribute / ejectable | Vocab stale or typo | `zig build docs-component-previews` |
| Identity test fails after SDK edit | Canonical source drifted from library builder | Update `src/tooling/components/*` to match library trees |

## Related pages

<CardGroup>
  <Card title="Native UI markup" href="/native-ui">
    Elements, attributes, flex layout, templates, imports, slots, and LSP/check semantics for `.native` views.
  </Card>
  <Card title="Theming" href="/theming">
    Design tokens, live re-resolution, and token-only restyles across the catalog without ejecting.
  </Card>
  <Card title="App model" href="/app-model">
    Model, Msg, and update wiring—how component bindings and handlers attach without mutating state in markup.
  </Card>
  <Card title="State and data flow" href="/state-data-flow">
    Derive-don’t-store, message dispatch, text editing paths, and effects as the side-effect channel.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    `native eject component`, `check`, `dev`, and related flags, outputs, and failure modes.
  </Card>
  <Card title="Automation" href="/automation">
    Accessibility snapshots and widget driving against the same retained widget trees components produce.
  </Card>
</CardGroup>

---

## 10. Windows and surfaces

> Multi-window apps, native surface composition, OS window chrome, GPU surfaces, and host presentation on macOS and desktop peers.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/10-windows-and-surfaces.md
- Generated: 2026-07-09T08:19:19.808Z

### Source Files

- `docs/src/app/windows/layout.tsx`
- `docs/src/app/native-surfaces/layout.tsx`
- `src/platform/macos/appkit_host.m`
- `examples/deck/README.md`
- `src/runtime/gpu_surface_events.zig`

---
title: "Windows and surfaces"
description: "Multi-window apps, native surface composition, OS window chrome, GPU surfaces, and host presentation on macOS and desktop peers."
---

Native SDK presents desktop apps as **platform windows** that own a tree of **surfaces** — GPU canvases, native chrome and controls, and optional WebViews — composed through `ShellConfig` / `ShellWindow` / `ShellView`, reconciled by the runtime, and driven on the host by macOS AppKit (Metal-backed `gpu_surface`), Linux, and Windows system peers.

## Architecture

```mermaid
flowchart TB
  subgraph app [App layer]
    Scene["ShellConfig / scene"]
    WindowsFn["UiApp.windows_fn"]
    WindowView["UiApp.window_view"]
    ChromePass["UiApp.chrome display-list"]
    Effects["Effects.closeWindow / minimizeWindow"]
  end

  subgraph runtime [Runtime]
    ShellLayout["shell_layout + createShellViews"]
    WinViews["window_views create/focus/close"]
    GpuEvents["gpu_surface_events"]
    CanvasFrame["canvas_frame present"]
  end

  subgraph host [Platform host]
    AppKit["macOS AppKit / Metal surface view"]
    Linux["Linux system host"]
    Win32["Windows system host"]
  end

  Scene --> ShellLayout
  WindowsFn --> WinViews
  WindowView --> GpuEvents
  ChromePass --> CanvasFrame
  Effects --> WinViews
  ShellLayout --> AppKit
  ShellLayout --> Linux
  ShellLayout --> Win32
  CanvasFrame --> AppKit
  CanvasFrame --> Linux
  CanvasFrame --> Win32
  GpuEvents --> CanvasFrame
```

| Layer | Owns | Key types |
| --- | --- | --- |
| Manifest / scene | Startup window shape, titlebar, view tree | `ShellWindow`, `ShellView`, `app.zon` `.windows` / `.shell` |
| `UiApp` | Model-declared secondary windows, per-window views, chrome overlays | `WindowDescriptor`, `windows_fn`, `window_view`, `on_chrome` |
| Runtime | Create/focus/close, surface present, input → widgets | `createWindow`, `createView`, `GpuSurfaceFrameEvent` |
| Host | OS window chrome, Metal/software present, drag hit-test | AppKit `NativeSdkMetalSurfaceView`, platform `PlatformServices` |

## Default shape: one window, one canvas

Generated and most `UiApp` apps declare a single shell window whose fill view is a `gpu_surface`. The widget tree from `Options.view` renders into that canvas.

```zig
const shell_views = [_]native_sdk.ShellView{
    .{
        .label = "main-canvas",
        .kind = .gpu_surface,
        .fill = true,
        .gpu_backend = .metal,
        .gpu_pixel_format = .bgra8_unorm,
        .gpu_present_mode = .timer,
        .gpu_alpha_mode = .@"opaque",
        .gpu_color_space = .srgb,
        .gpu_vsync = true,
    },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
    .label = "main",
    .title = "My App",
    .width = 720,
    .height = 480,
    .views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
```

Wire the scene through `UiApp.Options.scene` (and keep the first window’s `titlebar` / size aligned with `app.zon` so the host startup create and the scene never disagree).

## Declaring windows

### Shell scene (`ShellWindow`)

| Field | Type / default | Role |
| --- | --- | --- |
| `label` | `[]const u8`, default `"main"` | Stable identity for restore merge and automation |
| `title` | optional | Window title at create |
| `width` / `height` | `f32`, default `720` / `480` | Default frame size |
| `x` / `y` | optional | Default origin when not restored |
| `resizable` | `bool`, default `true` | Host resize affordance |
| `restore_state` | `bool`, default `true` | Load frame from `windows.zon` by label |
| `restore_policy` | `clamp_to_visible_screen` \| `center_on_primary` | Placement when restoring |
| `titlebar` | `WindowTitlebarStyle`, default `.standard` | OS chrome style at create |
| `min_width` / `min_height` | `f32`, default `0` | Host content min-size floor (`contentMinSize` on macOS) |
| `views` | `[]const ShellView` | Surface tree for this window |

### `app.zon` windows

Top-level `.windows` (and `.shell.windows`) feed the host **startup** window create — including `titlebar` and min size — before the Zig scene loads:

```zon
.windows = .{
    .{ .label = "main", .title = "native-sdk", .width = 720, .height = 480, .restore_state = true },
},
```

Supported `titlebar` string values: `"standard"`, `"hidden_inset"`, `"hidden_inset_tall"`, `"chromeless"`. Unknown styles fail manifest parse.

### Imperative runtime windows

```zig
const info = try runtime.createWindow(.{
    .label = "tools",
    .title = "Tools",
    .default_frame = native_sdk.geometry.RectF.init(80, 80, 420, 320),
    .titlebar = .standard,
});
try runtime.focusWindow(info.id);
```

### JavaScript (`js_window_api`)

When bridge permissions allow `window` and an allowed origin is configured:

```javascript
const win = await window.zero.windows.create({
  label: "tools",
  title: "Tools",
  width: 420,
  height: 320,
});
const all = await window.zero.windows.list();
await window.zero.windows.focus(win.id);
await window.zero.windows.close(win.id);
```

Zig side typically sets `.js_window_api = true` and includes `native_sdk.security.permission_window` in `.security.permissions`.

## Multi-window apps (`windows_fn`)

Secondary windows are **model-declared**. Presence in the slice returned by `Options.windows_fn` **is** visibility: the runtime reconciles declared labels against live windows after every rebuild — creates missing, closes undeclared. There is no separate `visible` flag.

```zig
fn deckWindows(model: *const Model, scratch: *DeckApp.WindowsScratch) []const DeckApp.WindowDescriptor {
    var count: usize = 0;
    if (model.playlist_open) {
        scratch.windows[count] = .{
            .label = "playlist",
            .canvas_label = "playlist-canvas",
            .title = "Deck Playlist",
            .width = 512,
            .height = 440,
            .resizable = false,
            .titlebar = .chromeless,
            .on_close = .playlist_closed,
        };
        count += 1;
    }
    return scratch.windows[0..count];
}
```

| `WindowDescriptor` field | Constraint |
| --- | --- |
| `label` | Stable window identity (automation, close/minimize by label) |
| `canvas_label` | `gpu_surface` view label; must be unique app-wide (≠ main `canvas_label`) |
| `title` | Applied at creation only (no retitle channel) |
| `width` / `height` / `x` / `y` | Frame at create |
| `resizable`, `min_width`, `min_height` | Host resize policy |
| `titlebar` | Same enum as shell windows |
| `on_close` | `?Msg` when the **user** closes the window — never for a reconcile close the model initiated |

`Options.window_view` builds each secondary window’s widget tree by `window_label`. `windows_fn` requires `window_view` (asserted at init).

Reference implementations:

- `examples/deck` — fixed chromeless player + playlist unit toggled by model flag / `primary+L`
- `examples/system-monitor` — settings window while `settings_open`, standard chrome, `on_close` clears the flag

<Note>
Budget: `canvas_limits.max_ui_app_windows` = **4** model-declared secondary windows per `UiApp` (on top of the scene main window). Excess descriptors warn and are ignored. Platform total is `max_windows` = **16**.
</Note>

## Titlebar styles and OS chrome

`WindowTitlebarStyle` controls host chrome at window create:

| Style | Behavior |
| --- | --- |
| `standard` | Normal OS titlebar and system buttons |
| `hidden_inset` | Content under transparent titlebar; title hidden; macOS keeps traffic lights (~28pt band) |
| `hidden_inset_tall` | Same as hidden inset with unified-toolbar height (~52pt); lights vertically centered |
| `chromeless` | **No** OS titlebar and **no** system buttons (macOS borderless, Windows caption-less, Linux undecorated). Explicit opt-in for fully skinned UIs that draw **working** close/minimize controls |

<Warning>
Use `chromeless` only when the app draws real window actions (`fx.closeWindow` / `fx.minimizeWindow`). Ordinary apps should use `hidden_inset` / `hidden_inset_tall`, which keep OS controls.
</Warning>

### Chrome geometry (`WindowChrome` / `on_chrome`)

Hosts report overlay geometry so headers can pad for traffic lights / caption buttons without hardcoding:

| Field | Meaning |
| --- | --- |
| `insets` | Edge bands where OS chrome overlays content (macOS: top band + left lights; Windows: top band + right caption cluster) |
| `buttons` | Control cluster frame in content coordinates (centerline = `buttons.y + buttons.height / 2`) |
| `form_factor` | `unknown` \| `compact` \| `regular` (mobile size class; desktop often `unknown`) |
| `tabs_projected` | Host projects `ShellConfig.chrome.tabs` as real native tabs |

Subscribe with `UiApp.Options.on_chrome`:

```zig
pub fn onChrome(chrome: native_sdk.WindowChrome) ?Msg {
    return Msg{ .chrome_changed = chrome };
}
```

Reports arrive before the first view build and again when chrome changes (for example fullscreen zeroes the band). All-zero on standard chrome, fullscreen, or platforms without the concept.

### Window drag

Mark header chrome as a drag surface with markup `window-drag="true"` or the widget `.window_drag` flag. Press on background text/icons moves the window; press-claiming children (buttons) stay clickable. On window-drag down, the runtime skips the widget press pipeline so no control is left pressed (same as a native titlebar click).

Platform notes:

- macOS starts drag from the live pointer gesture (`performWindowDragWithEvent:`)
- Windows can mirror drag regions for `WM_NCHITTEST` / `HTCAPTION` when `set_window_drag_regions_fn` is wired
- Hosts without a drag service no-op the mirror

### App-drawn chrome pass

Fully skinned apps can draw hardware chrome via `UiApp.Options.chrome`: a fixed-count display-list pass with prefix (behind widgets) and suffix (in front). `examples/deck` machines absolute geometry from a layout table and pins command counts in tests — the runtime rejects a build that misses its declared count.

## Surfaces

A window is a declared tree of surfaces. Parent/edge/fill fields drive shell layout (`shell_layout`).

### Surface kinds (`ViewKind`)

| Kind | Role |
| --- | --- |
| `gpu_surface` | Canvas for native UI / custom drawing |
| `webview` | Embedded web content (`url`, engine via platform) |
| `toolbar`, `titlebar_accessory`, `sidebar`, `statusbar` | Docked native chrome regions |
| `split`, `stack` | Layout containers |
| `button`, `icon_button`, `list_item`, `checkbox`, `toggle`, `segmented_control`, `text_field`, `search_field`, `label`, `spacer`, `progress_indicator` | Native controls with command / a11y metadata |

### Composition patterns

**Native shell around WebView** (toolbar + split + webview + status):

```zig
const shell_views = [_]native_sdk.ShellView{
    .{ .label = "toolbar", .kind = .toolbar, .edge = .top, .height = 52 },
    .{ .label = "refresh", .kind = .button, .parent = "toolbar", .text = "Refresh", .command = "app.refresh" },
    .{ .label = "body", .kind = .split, .fill = true, .axis = .row },
    .{ .label = "sidebar", .kind = .sidebar, .parent = "body", .width = 240 },
    .{ .label = "main", .kind = .webview, .parent = "body", .url = "zero://inline", .fill = true },
    .{ .label = "status", .kind = .statusbar, .edge = .bottom, .height = 32, .text = "Ready" },
};
```

**Canvas + WebView siblings** — `examples/gpu-surface`: Metal `gpu_surface` and WebView in one split, plus native toolbar/status.

**Canvas-first panes** — parent a WebView to the canvas view and keep it snapped with `UiApp.Options.web_panes` (label, anchor semantics label, url, reload token). Pane URLs obey `security.navigation.allowed_origins`. A scene whose only webviews are children does not grow an implicit full-window `main` webview (`sceneNeedsMainWebView`).

### Imperative views

```zig
const info = try runtime.createView(.{
    .window_id = window_id,
    .label = "sync-status",
    .kind = .label,
    .parent = "status",
    .text = "Syncing",
});
_ = try runtime.updateView(info.window_id, info.label, .{ .text = "Synced" });
```

Gate optional chrome with capability checks:

```zig
if (runtime.supports(.native_views)) { /* create toolbar */ }
if (runtime.supports(.gpu_surfaces)) { /* create gpu_surface */ }
```

## GPU surfaces and presentation

### Host backends

| Host | `gpu_surface` presentation |
| --- | --- |
| macOS system (AppKit) | Metal-backed child view (`NativeSdkMetalSurfaceView`) |
| Linux / Windows system | Software (CPU reference) renderer |
| Null platform (tests) | Fake presents for hermetic suites |
| Chromium / mobile hosts | Same public vocabulary where useful; unsupported ops fail explicitly |

### GPU options on `ShellView` / create

| Option | Typical values |
| --- | --- |
| `gpu_backend` | `.metal`, `.software`, `.none` |
| `gpu_pixel_format` | `.bgra8_unorm` |
| `gpu_present_mode` | `.timer` |
| `gpu_alpha_mode` | `.opaque`, `.premultiplied` |
| `gpu_color_space` | `.srgb`, `.display_p3` |
| `gpu_vsync` | `bool` |

### Present-before-show

Shell windows that contain any `gpu_surface` use `WindowShowMode.on_first_present`: the window is created ordered out and becomes visible only after the first canvas present completes, so users never see a blank frame. WebView-only windows stay `.immediate` (engine owns first paint). Hosts keep a short fallback deadline if first present never arrives. Explicit focus can override a still-deferred show.

### Frame path

```text
Host present complete
    → GpuSurfaceFrameEvent
    → RuntimeGpuSurfaceEvents.dispatchGpuSurfaceFrame
        · update gpu size / scale / nonblank / backend metadata
        · advance layout/disclosure tweens on recorded timestamps
        · sync native scroll drivers
        · dispatch Event.gpu_surface_frame → app
        · resolve input latency; flush deferred accessibility
```

Related events:

| Event | When |
| --- | --- |
| `gpu_surface_frame` | Each host frame completion (includes `occluded` logical heartbeats) |
| `gpu_surface_resized` | Surface frame / scale change |
| `gpu_surface_input` | Pointer, key, text, IME on the surface |

`UiApp.Options.on_frame` can map a presented `GpuFrame` into a Msg (for example deck’s playback frame clock while audio is playing). Occluded completions are not valid latency endpoints.

Diagnostics-rich `GpuSurfaceFrameEvent` fields (when enabled) include nonblank status, sample color, present mode, canvas frame budgets, widget revision/node counts, and first-frame / input latency budgets (defaults: frame interval ~16.67ms; first-frame budget 150ms).

Automation can assert presentation truth, for example `gpu_nonblank=true` after `native build -Dautomation=true`.

## Window actions

| API | Behavior |
| --- | --- |
| `fx.closeWindow(label)` | Real OS close by declared label; last-window follows host exit semantics. Fire-and-forget; unknown label no-ops. Prefer declarative undeclare for secondary windows |
| `fx.minimizeWindow(label)` | Real OS minimize (Dock / taskbar / GTK) |
| `runtime.closeWindow` / `minimizeWindow` | Runtime service path used by effects and bridge |
| User titlebar close on model window | Dispatches descriptor `on_close` Msg; model must clear its open flag or the next reconcile recreates the window |

Deck chromeless keys wire Msgs to these effects so skin controls are real, not decorative.

## State persistence

`window_state.Store` persists geometry to `windows.zon` in the app state directory. Merge key is window `label` (or `id`). Restored: frame, maximized, fullscreen, scale. Titles continue to come from `app.zon` / create options. Empty or malformed labels are ignored on load.

## Limits and errors

| Constant | Value |
| --- | --- |
| `max_windows` | 16 |
| `max_ui_app_windows` | 4 secondary `UiApp` windows |
| `max_views` | 32 |
| `max_window_label_bytes` | 64 |
| `max_window_title_bytes` | 128 |
| `max_gpu_surface_scroll_drivers` | 16 per gpu-surface view |

Representative platform errors: `WindowLimitReached`, `DuplicateWindowLabel`, `WindowNotFound`, `ViewLimitReached`, `UnsupportedViewKind`, `InvalidGpuSurfacePacket`, `CrossWindowViewDenied`.

## Platform matrix (desktop)

| Capability | macOS AppKit | Linux | Windows |
| --- | --- | --- | --- |
| Multi-window create/focus/close | yes | yes | yes |
| `hidden_inset` / tall | yes | degrades toward standard | degrades toward standard |
| `chromeless` | borderless NSWindow | undecorated | caption-less |
| `WindowChrome` insets | traffic lights + band | best-effort / zero | caption buttons + band |
| `gpu_surface` | Metal | software | software |
| Present-before-show | yes | yes (null-tested) | yes |
| Native views / controls | yes | yes | yes |

Use `runtime.supports(.gpu_surfaces)` / `.native_views` before optional affordances. See [Platform support and security](/platform-security) for the full host matrix.

## Examples to run

| Example | What it proves |
| --- | --- |
| `examples/deck` | Two chromeless windows, skin window keys, chrome pass, `windows_fn` |
| `examples/system-monitor` | Tall hidden-inset titlebar, `on_chrome` padding, settings secondary window |
| `examples/gpu-surface` | Native toolbar + Metal surface + WebView split |
| `examples/canvas-preview` | Canvas-first WebView panes anchored to widgets |

```sh
native dev                          # from an example directory
native test -Dplatform=null         # hermetic multi-window / chrome suites
```

## Troubleshooting

| Symptom | Check |
| --- | --- |
| Blank window flash on launch | Canvas shell windows should use present-before-show; confirm a `gpu_surface` is in the scene so `shellWindowShowMode` returns `.on_first_present` |
| Secondary window never appears | Model flag must leave it in `windows_fn` output; `canvas_label` must be unique; stay within `max_ui_app_windows` |
| Secondary window reappears after close | Handle `on_close` and clear the model open flag (or accept re-create) |
| Header under traffic lights | Subscribe `on_chrome`; pad leading content by `insets.left`/`insets.right` and match band height |
| Chromeless window cannot close | Wire real `fx.closeWindow` / `fx.minimizeWindow` (or keep OS buttons via hidden styles) |
| Drag does nothing | Set `window-drag` on the header; ensure press is not on a button child; confirm host supports drag |
| GPU surface missing | `runtime.supports(.gpu_surfaces)`; macOS Metal vs software peers; `InvalidGpuSurfacePacket` in logs |
| Titlebar style ignored at startup | Align `app.zon` first window `titlebar` with scene `ShellWindow.titlebar` |

## Related pages

<CardGroup>
  <Card title="App model" href="/app-model">
    Model, Msg, update loop, and how UiApp owns rebuilds across windows.
  </Card>
  <Card title="Native UI markup" href="/native-ui">
    Elements, window-drag, flex layout, and bindings compiled into canvas trees.
  </Card>
  <Card title="Theming" href="/theming">
    Design tokens, live re-resolution, and chrome passes for skinned windows.
  </Card>
  <Card title="Menus, dialogs, and tray" href="/menus-dialogs-tray">
    OS-owned UI that coexists with engine-drawn surfaces.
  </Card>
  <Card title="Web engines" href="/web-engines">
    WebView composition beside gpu_surface canvases.
  </Card>
  <Card title="Runtime and effects" href="/runtime-effects">
    Effect channel including closeWindow and minimizeWindow.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix and bridge permission boundaries.
  </Card>
  <Card title="Automation" href="/automation">
    Snapshots, gpu_nonblank asserts, and multi-window driving.
  </Card>
</CardGroup>

---

## 11. Menus, dialogs, and tray

> App menus, context menus, system dialogs, tray status items, and how OS-owned UI coexists with engine-drawn widgets.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/11-menus-dialogs-and-tray.md
- Generated: 2026-07-09T08:20:25.677Z

### Source Files

- `docs/src/app/menus/layout.tsx`
- `docs/src/app/dialogs/layout.tsx`
- `docs/src/app/tray/layout.tsx`
- `src/runtime/canvas_widget_context_menu.zig`
- `examples/command-app/README.md`

---
title: "Menus, dialogs, and tray"
description: "App menus, context menus, system dialogs, tray status items, and how OS-owned UI coexists with engine-drawn widgets."
---

Native SDK routes app menus, tray actions, and (where supported) system dialogs through platform hosts, while canvas widgets stay engine-drawn. Menu and tray selections re-enter the app as `Event.command` with a typed `CommandSource`; context menus dispatch typed `Msg` values from declared items; file and message dialogs run as blocking platform services on the effect/capability boundary.

```text
                    ┌─────────────────────────────────────┐
                    │  Entry points                        │
                    │  app.zon menus · toolbar · shortcut  │
                    │  tray · bridge · context-menu item   │
                    └──────────────┬──────────────────────┘
                                   │
          ┌────────────────────────┼────────────────────────┐
          ▼                        ▼                        ▼
   configureMenus()         createTray() /            showOpenDialog /
   (native app menu)        status_item               showSaveDialog /
          │                        │                  showMessageDialog
          ▼                        ▼                        │
   Event.command             Event.command                  │
   source=.menu              source=.tray                   ▼
          │                        │               OpenDialogResult /
          └────────────┬───────────┘               MessageDialogResult
                       ▼                           (or bridge JSON)
              on_command / event_fn
              (same command id map)
```

## Ownership model

| Surface | Owner | Authoring | Result path |
| --- | --- | --- | --- |
| App / window menu bar | OS host (`configureMenus`) | `app.zon` `.menus` or `runWithOptions.menus` | `Event.command`, `source = .menu` |
| Widget context menu | OS `NSMenu` when available; else anchored canvas surface | `<context-menu>` or `ElementOptions.context_menu` | Typed `Msg` via selection |
| System file/message dialogs | OS file panels / alert sheets | `Runtime.show*Dialog` or bridge `native-sdk.dialog.*` | Paths / `MessageDialogResult` |
| Tray / menu-bar extra | OS status item | `Runtime.createTray` or `UiApp.Options.status_item` | `Event.command`, `source = .tray` |
| Canvas `dialog` / `dropdown-menu` widgets | Engine-drawn surfaces | Markup / component catalog | Model flags + `on-dismiss` / `on-press` |

Declare one command id (for shell actions) or one context-menu item set (for row actions). Do not fork a second menu tree for hosts that lack a native presenter — the runtime chooses presentation.

## App menus

Menus are declarative top-level bars with command-backed items. Generated runners load `app.zon` menus; Zig can override at `runWithOptions`.

### Manifest shape (`app.zon`)

```zon
.menus = .{
    .{
        .title = "View",
        .items = .{
            .{ .label = "Sync", .command = "app.sync", .key = "s", .modifiers = .{ "primary" } },
        },
    },
},
```

`examples/command-app` lists `menus` under `capabilities` and wires the same `app.sync` command from toolbar, menu, tray, shortcut, and bridge.

### Zig types

```zig
const view_items = [_]native_sdk.MenuItem{
    .{
        .label = "Refresh",
        .command = "app.refresh",
        .key = "r",
        .modifiers = .{ .primary = true },
    },
    .{ .separator = true },
};

const menus = [_]native_sdk.Menu{
    .{ .title = "View", .items = &view_items },
};

try runner.runWithOptions(app.app(), .{
    .app_name = "native-shell",
    .menus = &menus,
}, init);
```

| Field | Type | Notes |
| --- | --- | --- |
| `Menu.title` | `[]const u8` | Required; non-empty |
| `Menu.items` | `[]const MenuItem` | Shared across menus under item budget |
| `MenuItem.label` | `[]const u8` | Required unless `separator` |
| `MenuItem.command` | `[]const u8` | Valid command id |
| `MenuItem.key` / `modifiers` | shortcut fields | Optional accelerator |
| `MenuItem.separator` | `bool` | Divider; skips label/command checks |
| `MenuItem.enabled` / `checked` | `bool` | Defaults `true` / `false` |

### Limits and validation

| Constant | Value |
| --- | --- |
| `max_menus` | 16 |
| `max_menu_items` (total items across all menus) | 128 |
| `max_menu_title_bytes` | 64 |
| `max_menu_item_label_bytes` | 128 |
| `max_menu_command_bytes` | 128 |
| `max_menu_key_bytes` | 32 |

Invalid menus surface as `error.InvalidMenuOptions`, `error.InvalidCommand`, or `error.InvalidShortcut`. Shortcuts that require a modifier without one fail validation.

### Dispatch

On install, the runtime calls `PlatformServices.configureMenus`. Selecting a command-backed item emits `Event.command` with:

- `name` — item command string  
- `source` — `.menu`  
- `window_id` — active native window (tray/menu-before-focus may use `0`; handlers often treat `0` as window `1`)

macOS, Linux, and Windows system-WebView backends apply native app/window menus. Hosts that cannot implement menus return `error.UnsupportedService` for non-empty lists. Feature probe: `PlatformFeature.menus`.

## Context menus

Per-widget secondary-click menus are declared once. Presentation is host-chosen; authoring is not.

### Authoring

**Markup** — `<context-menu>` is a direct child of a pressable host. It takes no attributes. Children are `menu-item` (requires `on-press`), `separator`, and structure tags (`if` / `else` / `for`):

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

`examples/notes` uses this pattern for folder and note rows (Rename/Delete, Copy/Delete, and so on). Conditional items go **inside** the menu, not around a second `<context-menu>`.

**Zig** — `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)
```

### Resolution order (secondary button)

1. Deepest hit-route widget with a declared context menu.  
2. Editable text target → standard Cut / Copy / Paste / Select All (clipboard paths).  
3. Static text with a live selection → Copy only.  
4. No menu → `canvas_widget_context_press` (feeds `on_hold` / press-and-hold). Declared context menus always win over hold handlers.

### Presentation

| Host | Behavior |
| --- | --- |
| macOS | Native `NSMenu` at the pointer (`popUpMenuPositioningItem`; async nested tracking loop) |
| Linux / Windows today | Same declared items mounted as an anchored canvas surface via `canvas_widget_context_menu_request` |
| No presenter + app-declared menu | Fallback request → app loop mounts the same items |
| No presenter + zero-code edit/copy defaults | Degrade to keyboard clipboard paths only (no synthetic surface) |

Platform feature: `PlatformFeature.context_menus` (`show_context_menu_fn != null`).

### Budgets

| Budget | Cap | Meaning |
| --- | --- | --- |
| `max_context_menu_items` (platform) | 32 | One presented menu truncated at the pointer |
| `max_canvas_widget_context_menu_items_per_view` | 512 | Retained declared items across all widgets in a view |

Overflow of the per-view declaration budget is `error.WidgetContextMenuLimitReached`.

### Automation

Snapshots list declared labels (`context_menu=["Open","Delete"]`). Drive selections without the OS tracking loop:

```text
native automate widget-context-menu <view-label> <widget-id> <item-index>
```

Refuses undeclared menus, out-of-range indices, separators, and disabled items. Also: `widget-context-press` for the secondary-click gesture.

## System dialogs

Blocking OS dialogs for open/save/message. Distinct from engine-drawn `<dialog>` widgets (model-owned open flags, `on-dismiss`, theme tokens).

### Runtime API

```zig
const open = try runtime.showOpenDialog(.{
    .title = "Select a file",
    .default_path = "",
    .filters = &filters,
    .allow_directories = false,
    .allow_multiple = true,
}, &path_buffer);

const saved = try runtime.showSaveDialog(.{
    .title = "Save as",
    .default_name = "untitled.txt",
    .filters = &filters,
}, &path_buffer);

const answer = try runtime.showMessageDialog(.{
    .style = .warning,
    .title = "Confirm",
    .message = "Delete this item?",
    .informative_text = "This action cannot be undone.",
    .primary_button = "Delete",
    .secondary_button = "Cancel",
});
```

### Options summary

**Open** (`OpenDialogOptions` → `OpenDialogResult` with `count` + `paths`)

| Field | Default |
| --- | --- |
| `title` | `""` |
| `default_path` | `""` |
| `filters` | `&.{}` |
| `allow_directories` | `false` |
| `allow_multiple` | `false` |

**Save** (`SaveDialogOptions` → optional path)

| Field | Default |
| --- | --- |
| `title` | `""` |
| `default_path` | `""` |
| `default_name` | `""` |
| `filters` | `&.{}` |

**Message** (`MessageDialogOptions` → `MessageDialogResult`)

| Field | Default |
| --- | --- |
| `style` | `.info` (`info` / `warning` / `critical`) |
| `title` / `message` / `informative_text` | `""` |
| `primary_button` | `"OK"` |
| `secondary_button` / `tertiary_button` | `""` |

Result enum: `primary`, `secondary`, `tertiary`.

**FileFilter**

```zig
const filters = [_]native_sdk.FileFilter{
    .{ .name = "Images", .extensions = &.{ "png", "jpg", "gif" } },
    .{ .name = "All Files", .extensions = &.{ "*" } },
};
```

Runtime validates options before calling the platform (`validateOpenDialogOptions`, `validateSaveDialogOptions`, `validateMessageDialogOptions`). Feature probe: `PlatformFeature.dialogs`.

### Bridge (JavaScript)

Requires the builtin bridge with an explicit policy that grants the `dialog` permission. Commands:

| Command | Maps to |
| --- | --- |
| `native-sdk.dialog.openFile` | `showOpenDialog` |
| `native-sdk.dialog.saveFile` | `showSaveDialog` |
| `native-sdk.dialog.showMessage` | `showMessageDialog` |

JSON fields use camelCase (`defaultPath`, `allowMultiple`, `primaryButton`, …).

```javascript
const files = await window.zero.invoke("native-sdk.dialog.openFile", {
  title: "Select a file",
  allowMultiple: true,
});
```

### Engine-drawn dialogs

Canvas `<dialog>` / `WidgetKind.dialog` is in-app chrome: dismissible surface, theme tokens (`controls.dialog`), model open state. Prefer it for confirmations that should match app design; use system message dialogs for OS-native alerts and file pickers that must touch the host file system.

## System tray and menu-bar extras

### Runtime methods

| Method | Role |
| --- | --- |
| `runtime.createTray(options)` | Create or replace the tray icon and menu |
| `runtime.updateTrayMenu(items)` | Replace items without recreating the tray |
| `runtime.updateTrayTitle(title)` | Retitle the live status button (badge-style updates; no full rebuild) |
| `runtime.removeTray()` | Tear down the tray |
| `trayItemExists` / `trayCommandNameForItem` | Lookup for dispatch |

### TrayOptions / TrayMenuItem

| Field | Type | Default | Notes |
| --- | --- | --- | --- |
| `icon_path` | `[]const u8` | `""` | Icon file path |
| `title` | `[]const u8` | `""` | macOS menu-bar extra title; empty icon+title falls back to app name first letter |
| `tooltip` | `[]const u8` | `""` | Hover text |
| `items` | `[]const TrayMenuItem` | `&.{}` | Menu rows |
| `id` | `TrayItemId` (`u32`) | `0` | Required non-zero for command-backed items; unique |
| `label` | `[]const u8` | `""` | Required for non-separators |
| `command` | `[]const u8` | `""` | Preferred over compatibility fallback |
| `separator` | `bool` | `false` | Divider |
| `enabled` | `bool` | `true` | |

| Limit | Value |
| --- | --- |
| `max_tray_items` | 32 |
| `max_tray_title_bytes` | 64 |
| `max_tray_tooltip_bytes` | 256 |
| `max_tray_item_label_bytes` | 256 |
| `max_tray_item_command_bytes` | 128 |
| `max_tray_icon_path_bytes` | 4096 |

Validation failures return `error.InvalidTrayOptions` (missing labels, duplicate ids, zero id on non-separators, oversize fields).

### Dispatch

Tray clicks emit `Event.command` with `source = .tray` and `tray_item_id`. Command name is the item’s `command` when set; otherwise the compatibility name `"tray.action"`.

```zig
try runtime.createTray(.{
    .tooltip = "native-sdk",
    .items = &.{
        .{ .id = 1, .label = "Refresh", .command = "app.refresh" },
        .{ .separator = true },
        .{ .id = 2, .label = "Quit", .command = "app.quit" },
    },
});
```

### `UiApp` status item

Canvas-first apps install once via `UiApp.Options.status_item` (installing frame). Optional `status_item_fn` runs on install and after rebuilds: title-only changes call `updateTrayTitle` (no flicker); menu changes call `updateTrayMenu`. Static `status_item` still supplies icon/tooltip.

```zig
.status_item = .{
    .title = "NS",
    .tooltip = "Native SDK Canvas Preview",
    .items = &status_items,
},
// optional: .status_item_fn = statusItem,
```

See `examples/canvas-preview` for a live menu-bar extra sharing toolbar commands through `on_command`.

### Platform support

| Platform | Tray |
| --- | --- |
| macOS | Full (`NSStatusItem`; titled menu-bar extras) |
| Windows | Full |
| Linux | `error.UnsupportedService` until a portable status notifier is selected |
| Mobile | None |

Feature probe: `PlatformFeature.tray`. Missing tray-title support logs once and still applies menu updates.

## Shared command routing

Shell chrome converges on one command map:

| Source | `CommandSource` |
| --- | --- |
| App menu item | `.menu` |
| Keyboard shortcut | `.shortcut` |
| Toolbar / native control | `.toolbar` / `.native_view` |
| Tray item | `.tray` |
| WebView bridge | `.bridge` |
| Runtime internal | `.runtime` |

`examples/command-app` proves one `app.sync` handler for toolbar, View menu, tray, shortcut, and bridge. Prefer stable command ids in menus and tray items so `on_command` stays a pure name → action table. Context menus intentionally use typed `Msg` instead of command strings (row identity is in the message payload).

## Verification

| Check | How |
| --- | --- |
| Menu install | `native dev` / run; open app menu; status text or handler side effect |
| Command source | Log `command.source` / `@tagName` as command-app does |
| Context menu (native path) | Right-click declared row; macOS shows OS menu |
| Context menu (fallback) | Host without presenter mounts anchored surface with same labels |
| Automation | `widget-context-menu` / `widget-context-press` under `native test` |
| Dialogs | Call `showMessageDialog` / open dialogs from Zig; bridge only with `dialog` permission |
| Tray | `createTray` or `status_item`; click item → `source=.tray` |
| Feature gates | `runtime.supports(.menus|.tray|.dialogs|.context_menus)` / bridge `native-sdk.platform.supports` |
| Headless | `zig build test -Dplatform=null` (null platform fakes tray/dialogs for tests) |

## Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| Empty menus ignored / `UnsupportedService` | Host lacks menu service or empty list is a no-op path |
| `InvalidMenuOptions` / `InvalidCommand` | Empty title/label, bad command id, item budget exceeded |
| Tray create fails on Linux | Expected; tray not implemented |
| `InvalidTrayOptions` | Non-unique or zero `id` on command rows; missing label; >32 items |
| Context menu never shows | No declared menu and no text selection; or zero-code path without presenter |
| Bridge dialog rejected | Missing `dialog` permission / policy |
| Context automation errors | `ContextMenuUndeclared`, `ContextMenuItemOutOfRange`, `ContextMenuItemSeparator`, `ContextMenuItemDisabled` |
| Hold handler not firing on right-click | Declared `<context-menu>` on the route wins over `on_hold` |

## Related pages

<CardGroup>
  <Card title="Commands and keyboard shortcuts" href="/commands-shortcuts">
    Single command routing across toolbar, menu, tray, shortcut, and bridge entry points.
  </Card>
  <Card title="Capabilities" href="/capabilities">
    Guarded OS services including dialogs, clipboard, and effect-channel access boundaries.
  </Card>
  <Card title="Native UI markup" href="/native-ui">
    Context-menu authoring, dismissible surfaces, and press/hold interaction rules.
  </Card>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    OS window chrome, multi-window scenes, and host presentation.
  </Card>
  <Card title="Bridge and builtin commands" href="/bridge">
    `native-sdk.dialog.*` payloads, permissions, and async invoke.
  </Card>
  <Card title="Automation" href="/automation">
    `widget-context-menu`, snapshots, and deterministic UI driving.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix for menus, tray, dialogs, and security boundaries.
  </Card>
</CardGroup>

---

## 12. Commands and keyboard shortcuts

> Single command routing across toolbar, menu, tray, shortcut, and bridge entry points with keyboard binding constraints.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/12-commands-and-keyboard-shortcuts.md
- Generated: 2026-07-09T08:20:08.120Z

### Source Files

- `docs/src/app/keyboard-shortcuts/layout.tsx`
- `examples/command-app/build.zig`
- `examples/command-app/README.md`
- `src/runtime/builtin_bridge.zig`
- `src/embed/chrome.zig`

---
title: "Commands and keyboard shortcuts"
description: "Single command routing across toolbar, menu, tray, shortcut, and bridge entry points with keyboard binding constraints."
---

App actions converge on one `CommandEvent` path: menus, app shortcuts, toolbar and native controls, tray items, embed chrome taps, automation, and WebView bridge calls all dispatch a stable command id into `Event.command`. Keyboard shortcuts are registered bindings that fire that same path (source `.shortcut`) and also emit `Event.shortcut` plus a JavaScript `shortcut` event when the bridge is live.

## Architecture

```mermaid
flowchart TB
  subgraph entry["Entry points"]
    TB[Toolbar / native control]
    MN[Menu item]
    TR[Tray item]
    SC[App shortcut key chord]
    BR["Bridge: native-sdk.command.invoke"]
    RT["runtime.dispatchCommand"]
    CH[Mobile chrome tab / primary action]
  end

  subgraph platform["Platform events"]
    NC[native_command]
    MC[menu_command]
    TA[tray_action]
    SE[shortcut]
    BM[bridge_message]
  end

  subgraph runtime["Runtime flow"]
    DC["dispatchCommand + validateCommandName"]
    EV["Event.command / CommandEvent"]
    ES["Event.shortcut + JS emit"]
  end

  subgraph app["App handlers"]
    EH["App.event switch .command"]
    OC["UiApp Options.on_command → Msg"]
  end

  TB --> NC
  MN --> MC
  TR --> TA
  SC --> SE
  BR --> BM
  CH --> RT
  RT --> DC
  NC --> DC
  MC --> DC
  TA --> DC
  SE --> DC
  SE --> ES
  BM --> DC
  DC --> EV
  EV --> EH
  EV --> OC
```

Platform hosts deliver raw events; `src/runtime/flow.zig` maps them into a single `CommandEvent` (name, source, window, view label, optional tray item id) and runs `validateCommandName` before the app sees the event.

## Command model

### `CommandEvent` and sources

| Field | Type / default | Role |
| --- | --- | --- |
| `name` | `[]const u8` | Stable command id (action identity) |
| `source` | `CommandSource` (default `.runtime`) | Which entry point fired the action |
| `window_id` | `WindowId` (default `0`) | Native window that owned the input; may be `0` for window-less sources |
| `view_label` | `[]const u8` | Native control or child WebView label when applicable |
| `tray_item_id` | `TrayItemId` (default `0`) | Tray item for tray-sourced commands |

| `CommandSource` | Emitted by |
| --- | --- |
| `.runtime` | Direct `runtime.dispatchCommand` / embed `native_sdk_app_command` |
| `.menu` | Native menu item (`menu_command`) |
| `.shortcut` | Registered app keyboard shortcut |
| `.toolbar` | Native control under a `.toolbar` parent |
| `.tray` | Tray menu item |
| `.native_view` | Native control outside a toolbar ancestry |
| `.bridge` | `window.zero.commands.invoke` / `native-sdk.command.invoke` |

Match on `command.name` for behavior. Use `source` only when the app needs source-specific UX. Tray items without an explicit command fall back to the name `"tray.action"` while still setting `tray_item_id`.

### Command catalog

Manifest and runtime catalog entries share the same shape:

| Field | Default | Notes |
| --- | --- | --- |
| `id` | required | Command id |
| `title` | `""` | Display title for menus/catalog UI |
| `enabled` | `true` | Catalog flag (not a hard runtime gate on dispatch) |
| `checked` | `false` | Toggle/state presentation in catalog |

Limits (manifest / platform):

| Limit | Value |
| --- | --- |
| Max commands in catalog | `256` |
| Max command id / title bytes | `128` |
| Runtime `validateCommandName` max id bytes | `128` |

Rejected ids: empty, oversize, `"."` / `".."`, or containing `0`, `/`, `\`, newline, CR, or tab.

`app.zon` example (shared catalog + bindings):

```zon
.commands = .{
    .{ .id = "app.sync", .title = "Sync" },
},
.shortcuts = .{
    .{ .id = "app.sync", .key = "s", .modifiers = .{ "primary" } },
},
.menus = .{
    .{
        .title = "View",
        .items = .{
            .{ .label = "Sync", .command = "app.sync", .key = "s", .modifiers = .{ "primary" } },
        },
    },
},
```

Generated runners load `.commands` into runtime options. Native code can read the active catalog without reparsing the manifest:

```zig
var buffer: [32]native_sdk.Command = undefined;
const commands = runtime.listCommands(&buffer);
```

### Handling commands

Raw `App` event handler:

```zig
fn event(context: *anyopaque, runtime: *native_sdk.Runtime, event_value: native_sdk.Event) anyerror!void {
    _ = context;
    switch (event_value) {
        .command => |command| {
            if (std.mem.eql(u8, command.name, "app.sync")) {
                // one handler for every entry point
            }
        },
        else => {},
    }
}
```

`UiApp` maps command names into TEA messages via `Options.on_command`:

```zig
.on_command = struct {
    fn map(name: []const u8) ?Msg {
        if (std.mem.eql(u8, name, "app.sync")) return .sync;
        return null;
    }
}.map,
```

When `window_id` is `0` (for example some tray/menu paths), `UiApp` dispatches against the canvas window so the model still receives a coherent window context.

## Entry points

| Entry point | How it binds | Platform event → source |
| --- | --- | --- |
| Toolbar button | Shell view `command` under parent `kind = toolbar` | `native_command` → `.toolbar` |
| Other native control | Shell/view `command` without toolbar ancestry | `native_command` → `.native_view` |
| App / window menu | `MenuItem.command` (+ optional key chord) | `menu_command` → `.menu` |
| Tray item | `TrayMenuItem.command` (or fallback `"tray.action"`) | `tray_action` → `.tray` |
| App shortcut | `Shortcut.id` matches command id | `shortcut` → `.shortcut` **and** `Event.shortcut` |
| Bridge | `window.zero.commands.invoke(name)` | `bridge_message` → `.bridge` |
| Embed chrome | Tab / primary action `id` as command id | host command path → `.runtime` |
| Automation | `shortcut <id>` or bridge invoke payloads | same command path |

Shell views declare commands on controls:

```zon
.{
    .label = "sync-button",
    .kind = "button",
    .parent = "toolbar",
    .text = "Sync",
    .command = "app.sync",
},
```

`commandSourceForNativeView` walks the view parent chain; if any ancestor is `.toolbar`, the source is `.toolbar`, otherwise `.native_view`.

Mobile platform chrome (tabs / primary action) uses the same command path: projected system controls call `native_sdk_app_command` with the declared id; selection state stays in the model, not in the host bar.

## Keyboard shortcuts

### Registration

Declare in `app.zon`:

```zon
.shortcuts = .{
    .{ .id = "app.sync", .key = "s", .modifiers = .{ "primary" } },
    .{ .id = "command.palette", .key = "p", .modifiers = .{ "primary", "shift" } },
},
```

Generated runners register manifest shortcuts automatically. Override only when building the list in Zig (`RunOptions.shortcuts` or `Runtime.init` `.shortcuts`).

Zig form:

```zig
const shortcuts = [_]native_sdk.Shortcut{
    .{ .id = "app.sync", .key = "s", .modifiers = .{ .primary = true } },
};
```

### Modifiers

| Manifest / field | Meaning |
| --- | --- |
| `primary` | Command on macOS; Control on Windows and Linux |
| `command` | Explicit Command (macOS-oriented) |
| `control` | Explicit Control |
| `option` / `alt` | Option/Alt |
| `shift` | Shift |

At least one modifier is required for single-character keys and for `space`, `enter`, `tab`, and `backspace`, so registration cannot steal ordinary typing. Named navigation keys (`escape`, arrows) may omit modifiers.

### Keys

**Single-character portable keys:** letters, digits, and unshifted punctuation `=`, `-`, `,`, `.`, `/`, `;`, `'`, `[`, `]`, `\`, `` ` ``.

**Named keys:** `escape`, `enter`, `tab`, `space`, `backspace`, `arrowleft`, `arrowright`, `arrowup`, `arrowdown` (case-insensitive).

### Limits and validation

| Constraint | Value / rule |
| --- | --- |
| Max shortcuts | `64` |
| Max shortcut id bytes | `64` |
| Max key bytes | `32` |
| Duplicate id | rejected (`DuplicateShortcut`) |
| Same key + colliding modifiers (per target platforms) | rejected |
| Invalid key / missing required modifier | `InvalidShortcut` |

Hosts validate and install the table at set time (`validateShortcut` on macOS/Linux/Windows/null platforms). Capability flag for apps that use them: `"shortcuts"` in `app.zon` capabilities.

### Delivery

When a registered chord matches the focused native window:

1. `dispatchCommand` with `source = .shortcut`, `name = shortcut.id`
2. `Event.shortcut` (`ShortcutEvent`: `id`, `key`, `modifiers`, `window_id`)
3. Optional JS emit on `window.zero` (`shortcut` detail)

Zig:

```zig
.shortcut => |shortcut| {
    // optional: react to chord details
    _ = shortcut.id;
},
.command => |command| {
    // primary action path; source == .shortcut for keyboard
},
```

JavaScript:

```ts
window.zero.on("shortcut", (event) => {
  // event.id, event.command (alias of id), event.key, event.windowId, event.modifiers
});
```

Prefer mapping the shared `command` event (or `on_command`) for application logic so menu/toolbar/bridge stay equivalent. Use `Event.shortcut` / JS `shortcut` only when the chord itself matters.

### Widget focus vs app shortcuts

Canvas keyboard routing is separate from app shortcuts:

1. Focused widget bound handler  
2. Structural consume (editable text kinds consume keys; control intents consume their keys)  
3. `UiApp` `Options.on_key` fallback for unclaimed `key_down`

App-level chrome shortcuts deliberately require modifiers on character keys so they do not compete with typing. Bare media-style keys belong in `on_key`, which yields to every consuming widget.

### Platform notes

- Shortcuts are scoped to the native window that receives the key event.
- System WebView hosts on macOS, Linux, and Windows install shortcut tables through the platform backends.
- Chromium (CEF) shortcut delivery is supported on the macOS CEF host; use the system WebView backend where CEF is unavailable if native shortcut events are required.

## Bridge command API

When `js_window_api` is on and the built-in bridge policy allows the call, WebView content uses the same command path.

| Surface | Request | Permission |
| --- | --- | --- |
| `window.zero.commands.invoke(name \| { name \| id })` | `native-sdk.command.invoke` | `command` |
| `window.zero.commands.list()` | `native-sdk.command.list` | `command` |

Invoke payload accepts `name` or `id`. Success result mirrors `CommandEvent`:

```json
{
  "name": "app.sync",
  "source": "bridge",
  "windowId": 1,
  "viewLabel": "",
  "trayItemId": 0
}
```

List result is the catalog array:

```json
[{ "id": "app.sync", "title": "Sync", "enabled": true, "checked": false }]
```

Denied origins or missing permission return bridge errors (for example `"Command API is not permitted"`). `command-app` enables this with:

- `permissions = .{ "command" }`
- `builtin_bridge` policies for `native-sdk.command.invoke` / `.list`
- `security.navigation.allowed_origins` including the WebView origin (e.g. `zero://inline`)

## Reference example: one command, five entry points

`examples/command-app` wires a single id `app.sync` through toolbar, menu, tray, primary+`s` shortcut, and WebView bridge, then updates a status label from one handler.

Run:

```sh
zig build run -Dplatform=macos -Dweb-engine=system
```

Headless suite (null platform):

```sh
zig build test -Dplatform=null
```

The example test dispatches, in order: toolbar `native_command`, `menu_command`, tray action, platform `shortcut`, and bridge invoke — asserting sources `.toolbar`, `.menu`, `.tray`, `.shortcut`, `.bridge` and a successful list response containing `"id":"app.sync"`.

## Automation

The automation protocol accepts `shortcut <id>` (for example `shortcut app.refresh`), which drives the same registered shortcut / command path used by the live host. Bridge-shaped automation can also post `native-sdk.command.invoke` payloads. Prefer command ids that already exist in the manifest catalog so UI, bridge, and agents share one vocabulary.

## Constraints and failure modes

| Condition | Result |
| --- | --- |
| Empty / path-like / oversize command name | `error.InvalidCommand` at `dispatchCommand` |
| Shortcut without required modifier | `error.InvalidShortcut` at registration / manifest validation |
| Duplicate shortcut id or colliding chord | `error.DuplicateShortcut` |
| More than 64 shortcuts | manifest / host rejection |
| Bridge invoke without `command` permission or allowed origin | bridge error response, no app command |
| Tray item with command but `id == 0` or separator | tray validation failure |
| Handler error inside `App.event` | recorded dispatch error; app continues (tests may use `.propagate`) |

## Verification checklist

- Declare the command once in `.commands` and reuse that id on menu, shortcut, toolbar, tray, and bridge.
- Confirm headless multi-source routing (`command-app` pattern or equivalent platform event injections).
- Confirm bridge: `commands.list()` returns the catalog; `commands.invoke` returns `source: "bridge"`.
- Confirm shortcut: chord updates the same UI path as the toolbar button; typing unmodified character keys still reaches text fields.
- Confirm capability/permission flags when packaging: `"shortcuts"`, `"menus"`, `"tray"`, `"command"` as used.

## Related pages

<CardGroup>
  <Card title="Menus, dialogs, and tray" href="/menus-dialogs-tray">
    OS menus and tray items that bind to the same command ids.
  </Card>
  <Card title="Bridge and builtin commands" href="/bridge">
    Host-to-content bridge payloads, async dispatch, and permission boundaries.
  </Card>
  <Card title="App model" href="/app-model">
    Model / Msg / update loop and how UiApp maps commands into messages.
  </Card>
  <Card title="State and data flow" href="/state-data-flow">
    Message dispatch and effects after a command is accepted.
  </Card>
  <Card title="Capabilities" href="/capabilities">
    Guarded OS services and the command permission boundary.
  </Card>
  <Card title="Automation" href="/automation">
    Agent command surface including shortcut and bridge invoke.
  </Card>
  <Card title="Native UI markup" href="/native-ui">
    Widget keyboard focus rules that outrank app shortcuts for typing.
  </Card>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    Window-scoped shortcut delivery and multi-window command window_id.
  </Card>
</CardGroup>

---

## 13. Capabilities

> Guarded OS services for notifications, clipboard, credentials, dialogs, file drops, and effect-channel access boundaries.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/13-capabilities.md
- Generated: 2026-07-09T08:20:13.965Z

### Source Files

- `examples/capabilities/README.md`
- `examples/capabilities/build.zig`
- `skill-data/core/references/bridge-security-native-capabilities.md`
- `src/runtime/effects.zig`
- `examples/effects-probe/README.md`

---
title: "Capabilities"
description: "Guarded OS services for notifications, clipboard, credentials, dialogs, file drops, and effect-channel access boundaries."
---

Native OS services are exposed through `PlatformServices`, `Runtime` methods, host-emitted events, and — for WebView apps — default-deny builtin bridge commands under `native-sdk.*`. Trusted Zig code calls the runtime directly. Web content never receives capability access unless each command is listed in `RuntimeOptions.builtin_bridge` with matching `security.permissions` and origin policy. In a `UiApp`, clipboard side effects go through the effects channel (`fx.writeClipboard` / `fx.readClipboard`) so `update` never holds a runtime handle.

## Access model

```text
┌─────────────────────┐     ┌──────────────────────────┐
│  Trusted Zig        │     │  WebView / JS            │
│  runtime.*          │     │  window.zero.invoke /    │
│  Event.files_dropped│     │  window.zero.os|clipboard│
└──────────┬──────────┘     └────────────┬─────────────┘
           │                             │
           │                             │ builtin_bridge Policy
           │                             │ + security.permissions
           │                             │ + origin allowlist
           ▼                             ▼
        ┌────────────────────────────────────────┐
        │  RuntimeSystemServices / PlatformServices │
        │  showNotification, clipboard, credentials │
        │  dialogs, openUrl, revealPath, …          │
        └────────────────────────────────────────┘
```

| Path | Who uses it | Gate |
| --- | --- | --- |
| `Runtime` methods (`showNotification`, `writeClipboard`, …) | Zig app / loop code | Platform service availability (`supports`) |
| Builtin bridge (`native-sdk.os.*`, `native-sdk.clipboard.*`, …) | Trusted WebView JS | `builtin_bridge.enabled` + per-command policy; dialog/OS/clipboard/credentials are always default-deny |
| Host events (`files_dropped`, lifecycle activate/deactivate) | Zig `event_fn` and optional WebView listeners | Host emission only; no permission grant required |
| Effects (`fx.writeClipboard` / `fx.readClipboard`) | `UiApp` `update` | Effect slots/keys; no bridge permission (loop-owned) |

`window.zero.os.*`, `window.zero.clipboard.*`, and `window.zero.credentials.*` exist when the host injects the JS surface; they still route through the same builtin command names and policy checks. `js_window_api` can open a limited permission fallback for window/view/command/platform helpers only — it does **not** unlock dialog, OS, clipboard, or credential commands.

## Capability inventory

| Capability | Zig surface | Bridge command | Permission | Host notes |
| --- | --- | --- | --- | --- |
| Feature probe | `runtime.supports(feature)` | `native-sdk.platform.supports` | `window` | Returns host + engine truth for `PlatformFeature` |
| Open URL | `runtime.openExternalUrl(url)` | `native-sdk.os.openUrl` | `network` | Also requires `security.navigation.external_links` allowlist |
| Notification | `runtime.showNotification(options)` | `native-sdk.os.showNotification` | `notifications` | Title required; subtitle/body optional |
| Message / file dialogs | `showMessageDialog` / `showOpenDialog` / `showSaveDialog` | `native-sdk.dialog.showMessage` / `openFile` / `saveFile` | `dialog` | Default-deny on the bridge |
| Reveal path | `runtime.revealPath(path)` | `native-sdk.os.revealPath` | `filesystem` | File manager reveal |
| Recent documents | `addRecentDocument` / `clearRecentDocuments` | matching `native-sdk.os.*` | `filesystem` | Platform recent-document list |
| Clipboard text / rich | `readClipboard` / `writeClipboard` / `*Data` | `native-sdk.clipboard.readText` / `writeText` / `read` / `write` | `clipboard` | Text path always; rich MIME on system hosts |
| Clipboard (UiApp) | `fx.writeClipboard` / `fx.readClipboard` | — | — | Effect channel; terminal Msg outcomes |
| Credentials | `setCredential` / `getCredential` / `deleteCredential` | `native-sdk.credentials.set` / `get` / `delete` | `credentials` | Keychain / libsecret / Credential Manager |
| File drops | `Event.files_dropped` | `window.zero.on("drop:files")` / `native-sdk:drop:files` | — | Host-emitted; system WebView hosts |
| App activation | `LifecycleEvent.activate` / `deactivate` | `app:activate` / `app:deactivate` | — | Host-emitted lifecycle |

Unsupported hosts reject with the standard unsupported-service error rather than no-oping.

## Permissions and policy

Named grants live in `src/security/root.zig`:

| Constant | String |
| --- | --- |
| `permission_window` | `window` |
| `permission_command` | `command` |
| `permission_view` | `view` |
| `permission_dialog` | `dialog` |
| `permission_filesystem` | `filesystem` |
| `permission_clipboard` | `clipboard` |
| `permission_network` | `network` |
| `permission_notifications` | `notifications` |
| `permission_credentials` | `credentials` |

Bridge policy is `bridge.Policy` / `BridgeCommandPolicy`:

```zig
.builtin_bridge = .{
    .enabled = true,
    .commands = &.{
        .{ .name = "native-sdk.os.showNotification", .permissions = &.{ "notifications" }, .origins = &.{ "zero://inline", "zero://app" } },
        .{ .name = "native-sdk.clipboard.readText", .permissions = &.{ "clipboard" }, .origins = &.{ "zero://inline", "zero://app" } },
        .{ .name = "native-sdk.clipboard.writeText", .permissions = &.{ "clipboard" }, .origins = &.{ "zero://inline", "zero://app" } },
        .{ .name = "native-sdk.credentials.set", .permissions = &.{ "credentials" }, .origins = &.{ "zero://inline", "zero://app" } },
        // … one entry per command the WebView may call
    },
},
.security = .{
    .permissions = &.{
        "window", "network", "filesystem", "notifications",
        "dialog", "clipboard", "credentials",
    },
    .navigation = .{
        .allowed_origins = &.{ "zero://inline", "zero://app" },
        .external_links = .{
            .action = .open_system_browser,
            .allowed_urls = &.{ "https://example.com/docs/*" },
        },
    },
},
```

Policy evaluation (`bridge.Policy.allows`):

1. `enabled` must be true.
2. Command name must match a listed policy entry.
3. App must hold every permission required by that entry.
4. Origin must match the entry’s `origins` list (or `"*"`).

Denied calls return `permission_denied` (for example `"Clipboard API is not permitted"`). Prefer exact origins over `"*"`.

`app.zon` can declare the same permission and capability strings for packaging/docs; the runtime gate that matters at call time is `RuntimeOptions.security` + `builtin_bridge`.

## Platform support discovery

`PlatformFeature` includes capability-relevant values such as `dialogs`, `clipboard_text`, `clipboard_rich_data`, `open_url`, `reveal_path`, `notifications`, `recent_documents`, `credentials`, `file_drops`, and `app_activation_events`.

Probe from Zig with `runtime.supports(.notifications)` (and peers). From JS:

```javascript
const r = {};
for (const feature of [
  "open_url", "reveal_path", "recent_documents", "notifications",
  "dialogs", "clipboard_text", "clipboard_rich_data", "credentials",
  "file_drops", "app_activation_events",
]) {
  r[feature] = await window.zero.invoke("native-sdk.platform.supports", { feature });
}
```

Feature names accept snake_case enum tags and camelCase aliases (`clipboardRichData`, `fileDrops`, `recentDocuments`, …) via `platformFeatureFromString`.

### Host matrix (capability-related)

| Feature | macOS system | macOS Chromium | Linux system | null (tests) |
| --- | --- | --- | --- | --- |
| `clipboard_text` / `clipboard_rich_data` | yes | yes | yes | yes |
| `notifications` | yes | yes | yes | yes |
| `dialogs` | yes | yes | yes | yes |
| `open_url` / `reveal_path` / `recent_documents` | yes | yes | yes | yes |
| `credentials` | yes (Keychain) | yes | yes when libsecret available | yes (fake) |
| `file_drops` | system engine only | no | system engine only | yes |
| `app_activation_events` | yes | yes | system engine only | yes |

Linux credentials and some audio features are live-probed; a missing library reports unsupported instead of a half-implemented path.

## Notifications

**Zig**

```zig
try runtime.showNotification(.{
    .title = "Build finished",
    .subtitle = "native-sdk",
    .body = "All checks passed.",
});
```

**Bridge**

```javascript
await window.zero.invoke("native-sdk.os.showNotification", {
  title: "Capabilities",
  subtitle: "native-sdk",
  body: "Notification bridge succeeded.",
});
// or: await window.zero.os.showNotification({ ... })
```

| Field | Constraint |
| --- | --- |
| `title` | Required, non-empty, ≤ `max_notification_title_bytes` (128) |
| `subtitle` | Optional, ≤ 128 |
| `body` / `message` | Optional, ≤ `max_notification_body_bytes` (1024) |

Empty title → `InvalidNotificationOptions`. NUL bytes rejected.

## Clipboard

### Runtime / bridge path

| Operation | Bridge | Payload / result |
| --- | --- | --- |
| Write text | `native-sdk.clipboard.writeText` | `{ text }` (aliases `data`, `value`) → `true` |
| Read text | `native-sdk.clipboard.readText` | `{}` → JSON string |
| Write MIME | `native-sdk.clipboard.write` | `{ mimeType, data }` → `true` |
| Read MIME | `native-sdk.clipboard.read` | `{ mimeType }` → `{ mimeType, data }` |

Defaults: missing MIME type is `text/plain`. Bounds: MIME ≤ 128 bytes, payload ≤ `max_clipboard_data_bytes` (65536). Supported rich types on system hosts include `text/html`, `text/rtf`, and `application/rtf`.

### Effects path (`UiApp`)

Clipboard effects share effect slots and keys with spawn/fetch/file. Writes and reads run **synchronously on the loop thread** (pasteboard is a main-thread service) but still deliver exactly one terminal `Msg` through the ordinary drain.

```zig
// in update / effect builders
fx.writeClipboard(.{
    .key = clipboard_key,
    .text = "Copied from Native SDK",
    .on_result = Effects.clipboardMsg(.clipboard_done),
});
fx.readClipboard(.{
    .key = clipboard_key,
    .on_result = Effects.clipboardMsg(.clipboard_done),
});
```

| Outcome (`EffectClipboardOutcome`) | Meaning |
| --- | --- |
| `ok` | Write landed / read text is in `EffectClipboardResult.text` (copy what the model keeps) |
| `failed` | Host has no clipboard arm, pasteboard error, or read over `max_effect_clipboard_bytes` |
| `rejected` | Slots busy, duplicate active key, write over bound, or no services bound |
| `cancelled` | `fx.cancel(key)` before delivery (op may already have completed) |

`max_effect_clipboard_bytes` equals `platform.max_clipboard_data_bytes` (65536). Oversize writes never silently truncate — they reject whole so a cut string cannot pass for the full clipboard.

## Credentials

Key shape: `{ service, account }` plus `secret` on set.

| Command | Result |
| --- | --- |
| `native-sdk.credentials.set` | stores secret → `true` |
| `native-sdk.credentials.get` | secret string or `null` if missing |
| `native-sdk.credentials.delete` | `true` if deleted, `false` if not found |

| Field | Max |
| --- | --- |
| `service` | 128 |
| `account` | 256 |
| `secret` | 4096 |

Empty service/account/secret → `InvalidCredentialOptions`. Hosts: macOS Keychain, Linux Secret Service/libsecret when available, Windows Credential Manager, null-platform fake store for tests.

## Dialogs

Bridge commands (always explicit policy):

| Command | Notable payload fields | Result |
| --- | --- | --- |
| `native-sdk.dialog.openFile` | `title`, `defaultPath`, `allowMultiple`, `allowDirectories` | path array or `null` |
| `native-sdk.dialog.saveFile` | `title`, `defaultPath`, `defaultName` | path string or `null` |
| `native-sdk.dialog.showMessage` | `style` (`info`/`warning`/`critical`), `title`, `message`, `informativeText`, buttons | result tag name string |

OS-owned dialogs coexist with engine-drawn widgets; for menu/tray placement see related pages. Do not expose open/save dialogs to untrusted origins.

## OS services (URL, reveal, recent)

| Command | Permission | Extra gate |
| --- | --- | --- |
| `native-sdk.os.openUrl` | `network` | URL must be `http://` or `https://`, ≤ 4096 bytes, and match `external_links` allowlist (`open_system_browser` action) |
| `native-sdk.os.revealPath` | `filesystem` | non-empty path ≤ 4096 |
| `native-sdk.os.addRecentDocument` | `filesystem` | path validation |
| `native-sdk.os.clearRecentDocuments` | `filesystem` | none |

Wildcard external URL patterns must be `http(s)://host/...*` with a path segment (for example `https://example.com/docs/*`). Patterns without a `/` after the host are rejected so `https://example.com*` cannot match `https://example.com.evil/...`.

## File drops and activation events

### File drops

Host → platform event `.files_dropped` → runtime:

1. Dispatches `Event.files_dropped` to the app `event_fn` with `FileDropEvent` (`window_id`, optional `view_label` / `point`, `paths`).
2. Emits WebView window event `drop:files` with JSON detail `{ windowId, paths, ... }`.

Listen from JS with `window.zero.on("drop:files", …)` or `window.addEventListener("native-sdk:drop:files", …)`. No bridge permission — the host owns the path list.

### App activation

Lifecycle activate/deactivate update Zig state and emit `app:activate` / `app:deactivate` to open windows (`detail` typically `{}`).

## Packaging metadata (associations and schemes)

`app.zon` may declare `file_associations` and `url_schemes` (example: extension `zncap`, scheme `native-sdk-capabilities`). These are packaging/registration metadata, not bridge commands. They are validated and emitted into package registration on macOS, Linux, and Windows.

## Size and bridge limits

| Limit | Value |
| --- | --- |
| Bridge request / response / result | `max_message_bytes` / `max_response_bytes` / `max_result_bytes` = 1 MiB each |
| Request id | 64 bytes |
| Command name | 128 bytes |
| Clipboard data | 65536 bytes |
| External URL / reveal / recent path | 4096 bytes each |

Oversized bridge requests return `payload_too_large`.

## Example: `examples/capabilities`

Reference app that wires full policy, inline WebView UI, file-drop status bar updates, and headless tests.

```sh
# system WebView (macOS example)
zig build run -Dplatform=macos -Dweb-engine=system

# headless / null platform tests
zig build test -Dplatform=null

# from repository root
zig build test-examples-native
```

The example’s `builtin_policies` list every capability command against `zero://inline` / `zero://app`, grants matching permissions, allows external docs URLs under `https://example.com/docs/*`, and asserts notification/clipboard/credential/file-drop/activation behavior through `TestHarness` + null platform counters.

## Errors

Bridge rejects use `error.code`:

| Code | Typical cause |
| --- | --- |
| `permission_denied` | Missing policy entry, origin, or grant |
| `unknown_command` | Not a registered builtin or app handler |
| `invalid_request` | Malformed payload, validation failure, unsupported service |
| `handler_failed` | Handler returned an error |
| `payload_too_large` | Request over bridge budget |
| `internal_error` | Unexpected runtime failure |

Zig validation errors include `InvalidNotificationOptions`, `InvalidClipboardOptions`, `ClipboardFieldTooLarge`, `InvalidCredentialOptions`, `CredentialFieldTooLarge`, `InvalidExternalUrl`, `NavigationDenied`, `InvalidRevealPath`, and peers mapped into bridge error responses.

## Verification

| Check | Signal |
| --- | --- |
| Policy denies by default | Bridge without `builtin_bridge` entry → `permission_denied` |
| Policy allows | Success JSON with `"ok":true` / `"result":…` |
| Notification | null-platform `notificationCount()` / last title in tests |
| Clipboard round-trip | write then read returns same text |
| Credentials | set → get → delete → get `null` |
| File drop | `drop_count` increments; last window event `drop:files` |
| Activation | `app:activate` / `app:deactivate` event names |
| Effects clipboard | fake executor records request; feed terminal outcome; assert Msg |

## Related pages

<CardGroup>
  <Card title="Runtime and effects" href="/runtime-effects">
    Effect kinds, clipboard effect outcomes, cancellation, and loop-thread delivery.
  </Card>
  <Card title="Bridge and builtin commands" href="/bridge">
    Host-to-content bridge payloads, builtin command surface, and permission boundaries.
  </Card>
  <Card title="Menus, dialogs, and tray" href="/menus-dialogs-tray">
    OS-owned dialogs and chrome next to engine-drawn widgets.
  </Card>
  <Card title="State and data flow" href="/state-data-flow">
    Effects as the only side-effect channel in the TEA update loop.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix and security defaults across platforms.
  </Card>
  <Card title="Testing" href="/testing">
    Headless truth drivers, effect tests, and harness patterns used by the capabilities example.
  </Card>
</CardGroup>

---

## 14. Web engines

> WebView composition on desktop hosts, CEF tooling and hosts, engine flags, and when native-rendered UI coexists with web content.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/14-web-engines.md
- Generated: 2026-07-09T08:20:00.742Z

### Source Files

- `docs/src/app/web-engines/layout.tsx`
- `src/tooling/cef.zig`
- `src/platform/windows/cef_host.cpp`
- `src/platform/linux/cef_host.cpp`
- `examples/webview/README.md`
- `third_party/cef/README.md`

---
title: "Web engines"
description: "WebView composition on desktop hosts, CEF tooling and hosts, engine flags, and when native-rendered UI coexists with web content."
---

Apps that load web content select a backend with `app.zon` field `.web_engine` (`"system"` or `"chromium"`). The Zig app model, bridge commands, navigation policy, and `WebViewSource` load path stay the same; the host implementation and packaging change. Native-rendered apps that never open a WebView do not select or link a web engine.

## Engine values

| Value | Meaning | Default |
| --- | --- | --- |
| `system` | OS WebView (no bundled browser) | Yes (`src/tooling/web_engine.zig` default) |
| `chromium` | Bundled CEF runtime | No |

Resolution order: build/CLI override → `app.zon` → default. Invalid values (for example `"blink"`) fail resolution with `InvalidWebEngine`.

```zig
// app.zon
.web_engine = "system",
// or on a supported Chromium host:
.web_engine = "chromium",
.cef = .{
  .dir = "third_party/cef/macos",
  .auto_install = false,
},
```

## Platform matrix

| Platform | `system` host | `chromium` host | Build gate |
| --- | --- | --- | --- |
| macOS | `appkit_host.m` + WKWebView | `cef_host.mm` + CEF | Chromium supported |
| Linux | `gtk_host.c` + WebKitGTK 6.0 | `cef_host.cpp` (layout/link path) | `-Dweb-engine=chromium` panics unless `-Dplatform=macos` |
| Windows | `webview2_host.cpp` + WebView2 | `cef_host.cpp` is a placeholder that includes the WebView2 host | Same macOS-only chromium panic; doctor reports Chromium unsupported |

The top-level and example build graphs enforce:

```text
-Dweb-engine=chromium currently requires -Dplatform=macos
```

Use `system` on Linux and Windows. Do not expect silent fallback to WebKitGTK/WebView2 when Chromium is requested on those platforms.

## Host selection

```text
app.zon .web_engine / -Dweb-engine
            │
            ▼
   resolve (manifest + overrides)
            │
     ┌──────┴──────┐
     ▼             ▼
  system        chromium
     │             │
  link OS       CEF layout check
  WebView       (optional native cef install)
  host source   + platform CEF host
```

| Platform | Engine | Host source | Link notes |
| --- | --- | --- | --- |
| macOS | system | `src/platform/macos/appkit_host.m` | WebKit framework |
| macOS | chromium | `src/platform/macos/cef_host.mm` | CEF includes, `libcef_dll_wrapper.a`, Chromium Embedded Framework, rpath `@executable_path/Frameworks` |
| Linux | system | `src/platform/linux/gtk_host.c` | gtk4, webkitgtk-6.0 |
| Linux | chromium | `src/platform/linux/cef_host.cpp` | CEF include/dir checks present; runtime selection blocked at build |
| Windows | system | `src/platform/windows/webview2_host.cpp` | Needs WebView2 SDK headers; without them WebView loads report `WebViewNotFound` |
| Windows | chromium | `src/platform/windows/cef_host.cpp` | Placeholder; not a real CEF browser host |

## Feature parity by engine

The public platform feature set is engine-gated. Unsupported paths return explicit errors (for example `UnsupportedService`, `UnsupportedViewKind`, `UnsupportedChildWebViews`) rather than silent no-ops.

### macOS

| Feature | system | chromium |
| --- | --- | --- |
| Main WebView, child WebViews | Yes | Yes |
| Tray, shortcuts, dialogs, clipboard, credentials, notifications | Yes | Yes |
| Native views, native control commands, app menus | Yes | No |
| GPU surfaces / scroll drivers / view-surface adoption | Yes | No |
| File drops | Yes | No |
| Audio playback / streaming / spectrum | Yes | No |

### Linux

| Feature | system | chromium (when linked) |
| --- | --- | --- |
| Main WebView | Yes | Host APIs exist; child WebViews return `UnsupportedChildWebViews` |
| Native views, GPU surfaces, menus, credentials, audio | Yes (where host provides them) | No (`web_engine == .system` required) |

### Windows

| Feature | system | chromium |
| --- | --- | --- |
| Main / child WebViews | Yes (WebView2 when installed) | Feature probes report unsupported |
| Native views, menus, GPU surfaces, audio | Yes on system host | No |

## WebView composition

Web content is not the default UI path. Two composition models coexist with native-rendered UI:

### WebView shell (window is mostly web)

- Startup window loads a `WebViewSource` (`html`, `url`, or `assets`).
- Reserved main label is `main`.
- Child WebViews use unique labels per parent window; global cap is `max_webviews` (16).
- Child options: `label`, `url`, `frame`, `layer`, `transparent`, `bridge_enabled`.
- Higher `layer` stacks above lower layers at the native host; DOM `z-index` does not cross WebViews.
- `bridge_enabled: true` injects the bridge only for trusted surfaces; leave false for untrusted pages.

```zig
// Platform services shape (runtime → host)
// createWebView(WebViewOptions{
//   .window_id = 1,
//   .label = "preview",
//   .url = "https://example.com",
//   .frame = geometry.RectF.init(24, 24, 420, 260),
//   .layer = 10,
//   .bridge_enabled = false,
// })
```

### Canvas + web pane (native UI owns the window)

`UiApp` can drive up to `max_web_panes` (4) panes via `Options.web_panes`. Each pane maps a scene webview label to a layout anchor (or explicit frame) and a URL under `security.navigation.allowed_origins`. This is the path for one live web region beside engine-drawn widgets on a system host that supports native views and GPU surfaces.

<Warning>
Chromium on macOS does not implement native child views or `gpu_surface` compositing. Canvas + web-pane and native-control coexistence require `web_engine = "system"`.
</Warning>

### Source kinds

| Kind | Constructor | Use |
| --- | --- | --- |
| `html` | `WebViewSource.html(bytes)` | Inline document (window source budget up to 65536 bytes) |
| `url` | `WebViewSource.url(bytes)` | Remote or local URL |
| `assets` | `WebViewSource.assets(.{ .root_path, .entry, .origin, .spa_fallback })` | Packaged frontend tree; default origin `zero://app` |

Navigation and create paths check `security.navigation.allowed_origins` before the host creates or navigates a view.

## Configure the engine

### Manifest

| Field | Type | Default | Notes |
| --- | --- | --- | --- |
| `.web_engine` | `"system"` \| `"chromium"` | `"system"` | Selects host backend |
| `.cef.dir` | path | `third_party/cef/macos` | Platform defaults rewrite bare macOS path to `linux` / `windows` subdirs when building for those platforms |
| `.cef.auto_install` | bool | `false` | When true and Chromium is selected, build may run `native cef install --dir <dir>` |

Also declare web-related capabilities when the app uses them (example: `webview`, `js_bridge` in `examples/webview/app.zon`).

### Build overrides

| Flag | Effect |
| --- | --- |
| `-Dweb-engine=system` | Force system host for this build |
| `-Dweb-engine=chromium` | Force CEF; requires macOS platform today |
| `-Dcef-dir=<path>` | CEF root for includes, wrapper, and runtime files |
| `-Dcef-auto-install=true` | Install prepared CEF during Chromium builds if missing |

CLI doctor accepts the same idea: `--web-engine`, `--cef-dir`, `--cef-auto-install`.

## CEF tooling

CEF binaries are not vendored in git. Install a prepared runtime (includes `libcef_dll_wrapper`) or, for advanced setups, official CEF archives plus a local wrapper build.

### Commands

```bash
native cef install
native cef install --dir third_party/cef/macos
native cef install --version 144.0.6+g5f7e671+chromium-144.0.7559.59
native cef install --source official --allow-build-tools --dir /path/to/cef
native cef path [--dir path]
native cef doctor [--dir path]
native cef prepare-release [--dir path] [--output path] [--version version]
```

| Command | Role |
| --- | --- |
| `install` | Download/verify/extract prepared (default) or official CEF |
| `path` | Print resolved install directory |
| `doctor` | Verify required layout entries for the host platform |
| `prepare-release` | Maintainer packaging of a release archive |

Pinned default prepared version: `144.0.6+g5f7e671+chromium-144.0.7559.59` (from `src/tooling/cef.zig`). Prepared archives download from the configured GitHub Releases base as `native-sdk-cef-<version>-<platform>.tar.gz`.

### Required layout

| Platform | Required entries |
| --- | --- |
| macOS | `include/cef_app.h`, `Release/Chromium Embedded Framework.framework/`, `libcef_dll_wrapper/libcef_dll_wrapper.a` |
| Linux | `include/cef_app.h`, `Release/libcef.so`, `libcef_dll_wrapper/libcef_dll_wrapper.a` |
| Windows | `include/cef_app.h`, `Release/libcef.dll`, `libcef_dll_wrapper/libcef_dll_wrapper.lib` |

Default dirs: `third_party/cef/macos`, `third_party/cef/linux`, `third_party/cef/windows`.

Cache directories used during download: macOS `~/Library/Caches/native/cef`, Linux `~/.cache/native/cef` (or `$XDG_CACHE_HOME/native/cef`), Windows `%LOCALAPPDATA%/native/cef`.

### Maintainer path

```bash
tools/cef/build-from-source.sh --platform macosarm64 --cef-branch <branch> --output zig-out/cef
```

Uses CEF `automate-git.py`, depot_tools, CMake, and the platform toolchain. App developers should use `native cef install`, not source builds.

### Local run bundling (macOS Chromium)

Chromium runs copy the CEF framework into `zig-out/bin/Frameworks` (and related layout paths) and stage helper libraries next to the executable. Packaging must ship the same CEF layout the binary was linked against.

```bash
zig build cef-bundle -Dcef-dir=/path/to/cef
```

## Example: WebView app

`examples/webview` declares `.web_engine = "system"`, bridge commands including `native.ping` and `native-sdk.webview.*`, and allowed origins for inline, app, and example.com content.

```bash
# From examples/webview
zig build run

# Chromium on macOS
zig build run -Dweb-engine=chromium -Dcef-auto-install=true

# Automation-enabled run
zig build run -Dautomation=true
```

Verification signals for Chromium smoke (`zig build test-webview-cef-smoke -Dplatform=macos -Dweb-engine=chromium`): automation ready, `webview.load` in logs, `native.ping` returns success with pong payload, child WebView create/setFrame/navigate/close succeed.

## Verify setup

```bash
native doctor --manifest app.zon
native doctor --manifest app.zon --web-engine chromium --cef-dir third_party/cef/macos
native cef doctor --dir third_party/cef/macos

# Cheapest Chromium host compile check (macOS, needs CEF layout)
zig build test-webview-cef-link

# Package layout contains framework/resources
zig build test-package-cef-layout -Dplatform=macos
```

`scripts/gate.sh fast` runs `cef-host-link` when `src/platform/macos/` changes. If the CEF layout is absent, the gate warns and skips rather than falsely green-lighting Chromium host compile.

Doctor checks include:

| Check id | Meaning |
| --- | --- |
| `webview-system` | OS backend present (WKWebView / WebKitGTK 6.0 / WebView2 registry probe) |
| `webview-chromium` | CEF layout ready, available but not selected, or unsupported on host |
| `webview-config` | Manifest/override resolution succeeded |

## When to use which engine

| Consideration | system | chromium (macOS) |
| --- | --- | --- |
| Bundle size | Minimal | Large (bundled CEF) |
| Rendering consistency | Follows OS version | Fixed CEF build |
| Startup | Fastest | CEF init cost |
| Native canvas coexistence | Full system feature set | No native views / GPU surfaces |
| Best fit | OS footprint, hybrid native+web, Linux/Windows | Chromium-stable web platform, complex frontend stacks that only need WebView chrome |

## Troubleshooting

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Build panics: chromium requires macOS | Linux/Windows `-Dweb-engine=chromium` | Use `system` or build with `-Dplatform=macos` |
| `missing CEF dependency for -Dweb-engine=chromium` | Incomplete layout | `native cef install --dir <cef-dir>` or `-Dcef-auto-install=true` |
| Doctor `webview-chromium` missing | Missing header, framework/so/dll, or wrapper | Install prepared runtime; re-run `native cef doctor` |
| Launch fails after package | Framework/resources not bundled or mismatched CEF | Rebuild package with Chromium resolved; verify `Contents/Frameworks/Chromium Embedded Framework.framework` |
| `WebViewNotFound` on Windows system | WebView2 headers/runtime missing | Install WebView2 runtime; ensure SDK headers for full host |
| Child WebView errors under Chromium on Linux | Explicit `UnsupportedChildWebViews` | Stay on system WebKitGTK for multi-WebView |
| Native views / GPU surface fail under Chromium | Feature gated to system host | Set `.web_engine = "system"` for hybrid UI |
| Official CEF install fails without CMake | Wrapper build required | Use prepared source, or `--source official --allow-build-tools` with CMake installed |

## Related pages

<CardGroup>
  <Card title="Bridge and builtin commands" href="/bridge">
    Host-to-content payloads, async dispatch, builtin webview/window commands, and origin permissions.
  </Card>
  <Card title="Frontend projects" href="/frontend">
    Managed React, Next, Svelte, and Vue shells, asset pipeline, and dev-server integration into WebViews.
  </Card>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    Multi-window composition, GPU surfaces, and host presentation alongside optional web content.
  </Card>
  <Card title="Native UI markup" href="/native-ui">
    Engine-drawn views and `web_panes` for a live WebView region inside a native scene.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix and navigation/bridge security boundaries for WebView apps.
  </Card>
  <Card title="Packaging" href="/packaging">
    Bundle layout for system and Chromium apps, including CEF framework placement.
  </Card>
  <Card title="Code signing" href="/code-signing">
    Signing constraints for macOS apps that embed Chromium Embedded Framework.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    `native cef`, `native doctor`, and build-related flags used with web engines.
  </Card>
</CardGroup>

---

## 15. Bridge and builtin commands

> Host-to-content bridge payloads, responses, async dispatch, builtin commands, and permission boundaries for WebView apps.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/15-bridge-and-builtin-commands.md
- Generated: 2026-07-09T08:22:04.012Z

### Source Files

- `src/runtime/builtin_bridge.zig`
- `src/runtime/bridge_payload.zig`
- `src/runtime/bridge_responses.zig`
- `src/runtime/async_bridge.zig`
- `skill-data/core/references/bridge-security-native-capabilities.md`

---
title: "Bridge and builtin commands"
description: "Host-to-content bridge payloads, responses, async dispatch, builtin commands, and permission boundaries for WebView apps."
---

The host-to-content bridge is the JSON request/response path from WebView JavaScript into the Zig runtime. `handleBridgeMessage` in the runtime flow first routes `native-sdk.*` prefixes through `RuntimeBuiltinBridge`, then async handlers in `bridge.Dispatcher.async_registry`, then sync handlers in `bridge.Dispatcher.registry`. Every path is default-deny: policy must allow the command for the message origin before a handler runs.

## Message path

```mermaid
sequenceDiagram
  participant JS as WebView JS
  participant Plat as platform.services
  participant Flow as handleBridgeMessage
  participant Builtin as RuntimeBuiltinBridge
  participant Disp as bridge.Dispatcher
  participant Slot as AsyncBridgeResponseSlot

  JS->>Plat: bridge message bytes
  Plat->>Flow: BridgeMessage
  alt native-sdk.* prefix
    Flow->>Builtin: handleBuiltinBridgeMessage
    Builtin-->>Plat: completeWebViewBridge(response)
  else async_registry hit
    Flow->>Disp: parse + policy.allows
    Disp->>Slot: reserveAsyncBridgeResponse
    Note over Slot: handler later calls AsyncResponder
    Slot-->>Plat: respondToBridge
  else sync registry
    Flow->>Disp: dispatch(raw, source)
    Disp-->>Plat: completeWebViewBridge(response)
  end
```

| Stage | Behavior |
| --- | --- |
| Parse | `bridge.parseRequest` requires `id`, `command`, optional `payload` (default `null`) |
| Builtin gate | Commands starting with `native-sdk.command.`, `.window.`, `.view.`, `.webview.`, `.platform.`, `.dialog.`, `.os.`, `.clipboard.`, or `.credentials.` |
| App handlers | Sync: `registry` + `HandlerFn`. Async: `async_registry` + `AsyncHandlerFn` |
| Complete | `platform.services.completeWebViewBridge(window_id, webview_label, response)`; automation may also `publishBridgeResponse` |

## Wire protocol

### Request

```json
{
  "id": "1",
  "command": "native.ping",
  "payload": { "source": "webview" }
}
```

| Field | Rules |
| --- | --- |
| `id` | Required; 1–64 bytes; no control chars, `"` or `\` |
| `command` | Required; 1–128 bytes; no control chars, `"`, `\`, `/`, or spaces |
| `payload` | Optional JSON value; omitted → `null` |

### Success response

```json
{
  "id": "1",
  "ok": true,
  "result": { "message": "pong", "count": 1 }
}
```

Handler results must be valid JSON values. Empty results become `null`. Invalid JSON from a handler becomes `handler_failed`.

### Error response

```json
{
  "id": "1",
  "ok": false,
  "error": {
    "code": "permission_denied",
    "message": "Bridge command is not permitted"
  }
}
```

### Limits

| Constant | Value |
| --- | --- |
| `max_message_bytes` | 1 MiB |
| `max_response_bytes` | 1 MiB |
| `max_result_bytes` | 1 MiB |
| `max_id_bytes` | 64 |
| `max_command_bytes` | 128 |
| `max_async_bridge_responses` | 64 concurrent async slots |
| `max_bridge_origin_bytes` | 512 (async source origin copy) |

Oversized raw messages return `payload_too_large`. Async slot exhaustion returns `internal_error` with `AsyncBridgeResponseLimitReached`.

## Invocation source

Each handler receives `bridge.Invocation`:

| Field | Meaning |
| --- | --- |
| `request.id` / `request.command` / `request.payload` | Parsed request |
| `source.origin` | Page origin (for example `zero://app`) |
| `source.window_id` | Calling window (default `1`) |
| `source.webview_label` | Calling WebView label (default `"main"`) |

Async handlers get a stable copy of origin and webview label in an `AsyncBridgeResponseSlot`; the label remains valid after the platform message buffer is reused.

## App-defined handlers

### Sync

```zig
fn ping(context: *anyopaque, invocation: native_sdk.bridge.Invocation, output: []u8) anyerror![]const u8 {
    _ = invocation;
    const self: *App = @ptrCast(@alignCast(context));
    self.ping_count += 1;
    return std.fmt.bufPrint(output, "{{\"message\":\"pong\",\"count\":{d}}}", .{self.ping_count});
}

// Dispatcher:
// .policy = .{ .enabled = true, .commands = &policies },
// .registry = .{ .handlers = &self.handlers },
```

Escape user-controlled strings with `bridge.writeJsonStringValue` so the response stays valid JSON.

### Async

Register on `async_registry`. Policy is checked before the handler runs. Respond exactly once via `AsyncResponder`:

| Method | Effect |
| --- | --- |
| `success(id, result_json)` | Writes success envelope and delivers via `respondToBridge` |
| `fail(id, code, message)` | Writes error envelope |
| `respond(raw_response)` | Delivers a fully formed response buffer |

A second `respond` after release returns `AsyncBridgeResponseAlreadyCompleted`.

### Policy

```zig
.bridge = .{
    .policy = .{
        .enabled = true,
        .permissions = &.{ /* runtime grants mirrored when set */ },
        .commands = &.{
            .{ .name = "native.ping", .origins = &.{ "zero://app" } },
            .{ .name = "native.secure", .permissions = &.{ "filesystem" }, .origins = &.{ "zero://app" } },
        },
    },
    .registry = .{ .handlers = &handlers },
    .async_registry = .{ .handlers = &async_handlers },
},
```

`Policy.allows` requires `enabled`, a matching command name, all listed permissions present in policy grants, and an origin match (exact or `"*"`). Empty `origins` means any origin passes the origin check (permissions still apply). Prefer exact origins over `"*"`.

## Builtin command catalog

Builtin dispatch lives in `RuntimeBuiltinBridge`. Prefix matching is exact namespace, then command string equality.

### Command routing

| Command | JS permission (fallback path) | Role |
| --- | --- | --- |
| `native-sdk.command.invoke` | `command` | Dispatches `CommandEvent` with `source = .bridge` |
| `native-sdk.command.list` | `command` | Lists `options.commands` as `{id,title,enabled,checked}` |

Invoke payload accepts `name` or `id`. Response includes `name`, `source`, `windowId`, `viewLabel`, `trayItemId`. The view label is empty when the caller is `main`.

### Platform

| Command | JS permission | Role |
| --- | --- | --- |
| `native-sdk.platform.supports` | `window` | Feature probe via `PlatformFeature` (`feature` or `name`; snake_case or camelCase aliases) |

### Windows

| Command | JS permission | Role |
| --- | --- | --- |
| `native-sdk.window.list` | `window` | List open windows |
| `native-sdk.window.create` | `window` | Create window (`label`, `title`, `width`/`height` default 720×480, `x`/`y`, `url`, `restoreState`) |
| `native-sdk.window.focus` | `window` | Focus by selector |
| `native-sdk.window.close` | `window` | Close by selector |

Window JSON: `id`, `label`, `title`, `open`, `focused`, `x`, `y`, `width`, `height`, `scale`.

### Views

| Command | JS permission | Role |
| --- | --- | --- |
| `native-sdk.view.create` | `view` | Create native/GPU/webview-backed view |
| `native-sdk.view.list` | `view` | List views in calling window |
| `native-sdk.view.update` | `view` | Patch frame/layer/visibility/text/command/url/… |
| `native-sdk.view.setFrame` | `view` | Required frame update |
| `native-sdk.view.setVisible` | `view` | Show/hide |
| `native-sdk.view.focus` | `view` | Focus by label |
| `native-sdk.view.focusNext` / `focusPrevious` | `view` | Focus cycle |
| `native-sdk.view.close` | `view` | Close view |

`windowId` in view/webview payloads must match the calling window or the runtime returns cross-window denial (`CrossWindowViewDenied` / `CrossWindowWebViewDenied` → `invalid_request`).

### WebViews

| Command | JS permission | Role |
| --- | --- | --- |
| `native-sdk.webview.create` | `window` | Child WebView; `main` label reserved |
| `native-sdk.webview.list` | `window` | List in calling window |
| `native-sdk.webview.setFrame` | `window` | Resize/move |
| `native-sdk.webview.navigate` | `window` | Navigate (not main); URL must pass navigation policy |
| `native-sdk.webview.setZoom` | `window` | Zoom |
| `native-sdk.webview.setLayer` | `window` | Stack order |
| `native-sdk.webview.close` | `window` | Close child WebView |

Create defaults: `transparent: false`, `bridge: false`, `layer: 0`. Frame requires positive width/height. Child WebViews get `window.zero` only when `bridge: true`. Navigate and create validate URL origins via navigation policy (`NavigationDenied` → `invalid_request`).

WebView JSON: `label`, `windowId`, `url`, frame fields, `layer`, `zoom`, `transparent`, `bridge`, `focused`, `open`.

### Dialogs, OS, clipboard, credentials

These families always need an explicit `builtin_bridge` policy entry. They do **not** use the `js_window_api` fallback (no JS permission is attached).

| Family | Commands |
| --- | --- |
| Dialog | `native-sdk.dialog.openFile`, `.saveFile`, `.showMessage` |
| OS | `native-sdk.os.openUrl`, `.showNotification`, `.revealPath`, `.addRecentDocument`, `.clearRecentDocuments` |
| Clipboard | `native-sdk.clipboard.readText`, `.writeText`, `.read`, `.write` |
| Credentials | `native-sdk.credentials.set`, `.get`, `.delete` |

Typical permission tags in policy: `dialog`, `network`, `notifications`, `filesystem`, `clipboard`, `credentials` (see `security.permission_*`).

## Permission boundaries

### Two allow paths for builtins

`allowsBuiltinBridgeCommand`:

1. If `options.builtin_bridge.enabled` is true → only `builtin_bridge.allows(command, origin)` (command listed, permissions, origins).
2. Else, for command/window/view/webview/platform only:
   - require non-null JS permission,
   - require `js_window_api = true`,
   - require origin in `security.navigation.allowed_origins`,
   - if runtime permissions are non-empty, require the JS permission **or** (for non-`window` permissions) the legacy `window` grant.

Dialog / OS / clipboard / credentials skip path 2 and deny unless path 1 allows them.

### Security grants

| Constant | String |
| --- | --- |
| `permission_window` | `"window"` |
| `permission_command` | `"command"` |
| `permission_view` | `"view"` |
| `permission_dialog` | `"dialog"` |
| `permission_filesystem` | `"filesystem"` |
| `permission_clipboard` | `"clipboard"` |
| `permission_network` | `"network"` |
| `permission_notifications` | `"notifications"` |
| `permission_credentials` | `"credentials"` |

Default navigation allowlist: `zero://app`, `zero://inline`. External link default action is `deny`.

### Example: explicit builtin policy

```zig
const app_permissions = [_][]const u8{
    native_sdk.security.permission_window,
    native_sdk.security.permission_dialog,
};

.security = .{
    .permissions = &app_permissions,
    .navigation = .{ .allowed_origins = &.{ "zero://app" } },
},
.js_window_api = true,
.builtin_bridge = .{
    .enabled = true,
    .commands = &.{
        .{ .name = "native-sdk.window.create", .permissions = &.{ "window" }, .origins = &.{ "zero://app" } },
        .{ .name = "native-sdk.webview.create", .permissions = &.{ "window" }, .origins = &.{ "zero://app" } },
        .{ .name = "native-sdk.dialog.openFile", .permissions = &.{ "dialog" }, .origins = &.{ "zero://app" } },
    },
},
```

`<Warning>`
`js_window_api = true` exposes convenience APIs such as `window.zero.windows.*` / `webviews.*` / `commands.*` / `views.*` / `platform.supports`. It does not bypass origin or permission checks.
`</Warning>`

## JavaScript surface

```javascript
// App command
const result = await window.zero.invoke("native.ping", { source: "webview" });

// Builtins (when allowed)
await window.zero.invoke("native-sdk.window.create", {
  label: "tools",
  title: "Tools",
  width: 420,
  height: 320,
});

await window.zero.invoke("native-sdk.webview.create", {
  label: "preview",
  url: "https://example.com",
  frame: { x: 24, y: 24, width: 480, height: 320 },
  layer: 10,
  bridge: false,
});

try {
  await window.zero.invoke("native.save", payload);
} catch (error) {
  console.error(error.code, error.message);
}
```

## Error codes

| Code | Typical cause |
| --- | --- |
| `invalid_request` | Malformed JSON; validation/limit/duplicate/reserved label; navigation denied; many builtin option errors |
| `unknown_command` | No app handler / unmatched builtin name within a family |
| `permission_denied` | Policy, origin, or missing permission (`Bridge command is not permitted`, or family-specific messages such as `Dialog API is not permitted`) |
| `handler_failed` | App handler error name, or non-JSON result |
| `payload_too_large` | Request exceeds `max_message_bytes` |
| `internal_error` | Unexpected runtime failure, async slot/origin copy failures, or unmapped builtin errors |

Builtin Zig errors map through `builtinBridgeErrorCode` / `builtinBridgeErrorMessage` (for example `WebView label "main" is reserved…`, `WebView windowId must match the calling window`).

## Constraints and failure modes

| Constraint | Failure signal |
| --- | --- |
| Default deny for bridge and builtins | `permission_denied` |
| Cross-window `windowId` on view/webview ops | `invalid_request` / cross-window message |
| Child WebView label `main` | `ReservedWebViewLabel` |
| URL not in navigation policy | `NavigationDenied` |
| Backend missing child WebView / focus / GPU kind | `invalid_request` with unsupported-backend message |
| Async respond twice | `AsyncBridgeResponseAlreadyCompleted` |
| More than 64 in-flight async responses | `AsyncBridgeResponseLimitReached` |

For large data, avoid stuffing entire payloads through one bridge result; use files, native resources, or chunked app protocols.

## Related pages

<CardGroup>
  <Card title="Web engines" href="/web-engines">
    WebView composition, CEF hosts, and when engine-drawn UI coexists with web content.
  </Card>
  <Card title="Capabilities" href="/capabilities">
    Guarded OS services (dialogs, clipboard, credentials, notifications) and effect-channel boundaries.
  </Card>
  <Card title="Commands and keyboard shortcuts" href="/commands-shortcuts">
    Single command routing across toolbar, menu, tray, shortcut, and bridge entry points.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix plus bridge permission and security defaults.
  </Card>
  <Card title="Frontend projects" href="/frontend">
    Managed web shells and `native dev` integration that load content into the bridge host.
  </Card>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    Multi-window composition and surfaces that builtin window/view/webview commands manipulate.
  </Card>
</CardGroup>

---

## 16. Frontend projects

> Managed React, Next, Svelte, and Vue shells, frontend asset pipeline, and native dev server integration for web frontends.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/16-frontend-projects.md
- Generated: 2026-07-09T08:20:48.717Z

### Source Files

- `docs/src/app/frontend/layout.tsx`
- `skill-data/core/references/frontend-assets.md`
- `examples/react/build.zig`
- `examples/next/build.zig`
- `examples/browser/frontend/app.js`

---
title: "Frontend projects"
description: "Managed React, Next, Svelte, and Vue shells, frontend asset pipeline, and native dev server integration for web frontends."
---

Web frontend apps in Native SDK are **WebView shells**: a Zig native process owns the window and host, while UI runs as web content. The shell switches content source via `native_sdk.frontend.sourceFromEnv` / `productionSource` (`src/frontend/root.zig`): when `NATIVE_SDK_FRONTEND_URL` is set (by `native dev`), the WebView loads the framework dev server; otherwise it serves static files from the configured `dist` root under the `zero://app` origin. Scaffold with `native init --frontend <next|vite|react|svelte|vue>` (web scaffolds always get a full `build.zig` graph because npm install/build must run before package and run).

<Note>
Default `native init` is native-rendered (`.native` + Zig `UiApp`), not a WebView frontend. Use `--frontend` only when shipping web UI inside a WebView.
</Note>

## Architecture

```mermaid
flowchart TB
  subgraph cli ["CLI / build"]
    init["native init --frontend …"]
    zigdev["zig build dev / native dev"]
    zigrun["zig build run"]
    pkg["native package --assets dist"]
  end

  subgraph appzon ["app.zon"]
    fcfg[".frontend dist/entry/dev"]
    sec["security.navigation.allowed_origins"]
  end

  subgraph shell ["Zig shell"]
    main["src/main.zig App + source_fn"]
    fe["native_sdk.frontend"]
    wv["WebViewSource url | assets"]
  end

  subgraph content ["Web content"]
    devsrv["npm run dev\nVite :5173 / Next :3000"]
    dist["frontend/dist or frontend/out"]
  end

  init --> fcfg
  init --> main
  zigdev --> fcfg
  zigdev -->|"spawn command, wait ready"| devsrv
  zigdev -->|"NATIVE_SDK_FRONTEND_URL\nNATIVE_SDK_MODE=dev\nNATIVE_SDK_HMR=1"| main
  main --> fe
  fe -->|"env set"| wv
  fe -->|"env unset"| dist
  dist --> wv
  devsrv --> wv
  zigrun --> dist
  pkg --> dist
  sec -.->|"must allow zero://app\nand dev origin"| wv
```

| Layer | Responsibility |
| --- | --- |
| `app.zon` `.frontend` | Dist path, entry HTML, SPA fallback, dev URL/command/timeout for the CLI |
| `src/main.zig` | `App.source` / `source_fn` bind env-aware WebView content |
| `src/frontend/root.zig` | `sourceFromEnv`, `productionSource`, `Config` defaults |
| `src/tooling/dev.zig` | Spawn frontend process, readiness poll, set env, launch binary, tear down on exit |
| `frontend/` | Framework project (Vite or Next); npm owns HMR and production build |
| `native package` / `bundle-assets` | Copy `frontend.dist` into package/build output |

## Scaffold

```bash
native init my_app --frontend react    # also: next | vite | svelte | vue
cd my_app
zig build dev                          # or: native dev
```

| `--frontend` | Production dist | Dev URL (template default) |
| --- | --- | --- |
| `react`, `vite`, `svelte`, `vue` | `frontend/dist` | `http://127.0.0.1:5173/` |
| `next` | `frontend/out` | `http://127.0.0.1:3000/` |
| `native` (default) | no npm frontend | N/A |

Web scaffolds write `build.zig`, `build.zig.zon`, `src/main.zig`, `src/runner.zig`, `app.zon`, `frontend/`, README, and a CI workflow. Template helpers live in `src/tooling/templates.zig` (`Frontend.distDir`, `devUrl`, `devPort`).

## Project layout

Framework examples (`examples/react`, `examples/next`, `examples/svelte`, `examples/vue`) share this shape:

:::files
my_app/
  app.zon                 # .frontend + navigation origins + webview capability
  build.zig               # frontend-install, frontend-build, run, dev, package
  src/main.zig            # App + sourceFromEnv
  src/runner.zig
  frontend/
    package.json
    # Vite: index.html, vite.config.*, src/
    # Next: next.config.js (output: "export"), app/
:::

Static chrome (no managed dev server) is separate: `examples/browser` serves `frontend/` via `productionSource` only and drives child WebViews through `window.zero` builtins.

## Manifest: `app.zon` `.frontend`

Manifest types: `FrontendConfig` / `FrontendDevConfig` in `src/primitives/app_manifest/types.zig`. Validation: relative `dist`/`entry`, HTTP(S) `dev.url`, non-empty `dev.command`, `timeout_ms > 0` (`validateFrontend`).

### Vite-family (React / Svelte / Vue)

```zig
.frontend = .{
    .dist = "frontend/dist",
    .entry = "index.html",
    .spa_fallback = true,
    .dev = .{
        .url = "http://127.0.0.1:5173/",
        .command = .{ "npm", "--prefix", "frontend", "run", "dev", "--", "--host", "127.0.0.1" },
        .ready_path = "/",
        .timeout_ms = 30000,
    },
},
.security = .{
    .navigation = .{
        .allowed_origins = .{ "zero://app", "zero://inline", "http://127.0.0.1:5173" },
        .external_links = .{ .action = "deny" },
    },
},
.capabilities = .{ "webview" },
```

### Next.js

```zig
.frontend = .{
    .dist = "frontend/out",
    .entry = "index.html",
    .spa_fallback = true,
    .dev = .{
        .url = "http://127.0.0.1:3000/",
        .command = .{ "npm", "--prefix", "frontend", "run", "dev" },
        .ready_path = "/",
        .timeout_ms = 30000,
    },
},
// allowed_origins include http://127.0.0.1:3000
```

Production export requires `output: "export"` in `frontend/next.config.js` so build output lands in `frontend/out`.

### Fields

| Field | Default | Role |
| --- | --- | --- |
| `dist` | `"dist"` | Built asset root copied into packages and loaded as assets |
| `entry` | `"index.html"` | HTML entry under `dist` |
| `spa_fallback` | `true` | Serve `entry` for unknown asset paths |
| `dev.url` | required if `dev` set | URL loaded when env is set; readiness host/port |
| `dev.command` | required if `dev` set | Argv spawned by `native dev` (must be non-empty) |
| `dev.ready_path` | `"/"` | HTTP path polled for 2xx/3xx |
| `dev.timeout_ms` | `30000` | Max wait before `error.Timeout` |

## Runtime source API

`native_sdk.frontend.Config` (`src/frontend/root.zig`):

| Field | Default |
| --- | --- |
| `dist` | `"dist"` |
| `entry` | `"index.html"` |
| `origin` | `"zero://app"` |
| `spa_fallback` | `true` |
| `dev_url_env` | `"NATIVE_SDK_FRONTEND_URL"` |

```zig
// Env present → WebViewSource.url; else assets under dist
return native_sdk.frontend.sourceFromEnv(self.env_map, .{
    .dist = "frontend/dist",
    .entry = "index.html",
});

// Always package assets (no env branch)
return native_sdk.frontend.productionSource(.{
    .dist = "frontend/dist",
    .entry = "index.html",
});
```

Framework shells set both a static `.source` (production default) and `.source_fn` that calls `sourceFromEnv` so `native dev` and `zig build run` share one binary:

```zig
// examples/react/src/main.zig pattern
.source = native_sdk.frontend.productionSource(.{ .dist = "frontend/dist" }),
.source_fn = source, // sourceFromEnv with matching dist/entry
```

`NATIVE_SDK_FRONTEND_ASSETS` is **not** read by `src/frontend/root.zig`. `examples/webview` treats it as an app-level convention to force `productionSource` when set.

## Dev server lifecycle

`src/tooling/dev.zig` `run`:

1. Require `metadata.frontend` and `frontend.dev` (`MissingFrontend` / `MissingDevConfig`).
2. Spawn `dev.command` in its own process group (if non-empty).
3. Poll `dev.url` + `ready_path` every 100ms until HTTP 2xx/3xx or `timeout_ms` (`Timeout`).
4. Launch `binary_path` with env: `NATIVE_SDK_FRONTEND_URL`, `NATIVE_SDK_MODE=dev`, `NATIVE_SDK_HMR=1`.
5. On shell exit (or CLI signal), kill frontend and app process groups.

Only `http://` and `https://` URLs parse; `ws://` fails `InvalidUrl`. Host `localhost` is resolved as `127.0.0.1` for the readiness TCP connect.

Framework HMR stays on the JS tooling (Vite/Next) because the WebView navigates to the live URL; `NATIVE_SDK_HMR=1` marks the shell session and does not implement framework HMR itself.

### Commands

```bash
# From an example or scaffolded app (build.zig wires native dev)
zig build frontend-install
zig build dev

# CLI: build shell + frontend flow when app owns the graph
native dev --manifest app.zon --binary zig-out/bin/react

# Zero-config verb path (no --binary): native builds then hands off to frontend flow
native dev [--url URL] [--command "npm --prefix frontend run dev"] [--timeout-ms 30000]

# Production path: install, build frontend, run shell against assets
zig build run
```

Example `build.zig` steps (React/Next): `frontend-install` → `npm install --prefix frontend`; `frontend-build` → `npm run build`; `run` depends on `frontend-build`; `dev` depends on exe + `frontend-install` and invokes `native dev --manifest app.zon --binary <exe>`.

## Package and assets

- `native package --assets <dir>` defaults assets to `metadata.frontend.dist` when present.
- Examples pass `--assets frontend/dist` (Vite) or `frontend/out` (Next) and depend on `frontend-build`.
- `native bundle-assets [app.zon] [assets] [output]` and `zig build bundle-assets` copy the assets tree and write `asset-manifest.zon` (`src/tooling/assets.zig`).

Build frontend **before** packaging; the package step does not run npm for you unless your `build.zig` `package` step depends on `frontend-build`.

## Security and origins

Allow exact origins the WebView will use:

| Mode | Typical `allowed_origins` |
| --- | --- |
| Packaged assets | `zero://app` |
| Vite/React/Svelte/Vue dev | `http://127.0.0.1:5173` |
| Next dev | `http://127.0.0.1:3000` |
| Inline demos only | `zero://inline` |

Do not use `"*"` for production navigation. Packaged HTML should use a strict CSP; dev CSP must also allow the local WebSocket endpoints the framework uses for HMR.

## Frontend ↔ native bridge

Examples probe the injected bridge:

```ts
// examples/react/frontend/src/App.tsx
setBridge((window as any).zero ? "available" : "not enabled");
```

Invoke host commands with:

```js
const result = await window.zero.invoke("native.ping", { source: "webview" });
```

Builtin window/WebView helpers need `js_window_api` / capability and bridge policy registration. The browser example enables `js_bridge` and policies for `native-sdk.webview.*` / `native-sdk.window.list`. See the bridge and web-engines pages for policy and engine choice.

## Example matrix

| Path | Stack | Dist | Dev | Notes |
| --- | --- | --- | --- | --- |
| `examples/react` | React + Vite | `frontend/dist` | `:5173` | Managed `sourceFromEnv` shell |
| `examples/vue` | Vue + Vite | `frontend/dist` | `:5173` | Same pattern |
| `examples/svelte` | Svelte + Vite | `frontend/dist` | `:5173` | Same pattern |
| `examples/next` | Next static export | `frontend/out` | `:3000` | `output: "export"` |
| `examples/browser` | Static HTML/JS | `frontend/` | none | Multi-WebView chrome via `window.zero` |
| `examples/webview` | Inline HTML + optional env | `dist` | optional | Demonstrates `NATIVE_SDK_FRONTEND_ASSETS` convention |

Standalone checkout of repo examples: `zig build run -Dnative-sdk-path=/path/to/native-sdk`.

## Environment variables

| Variable | Set by | Effect |
| --- | --- | --- |
| `NATIVE_SDK_FRONTEND_URL` | `native dev` | WebView loads this URL (`sourceFromEnv`) |
| `NATIVE_SDK_MODE` | `native dev` → `"dev"` | Session marker for the shell |
| `NATIVE_SDK_HMR` | `native dev` → `"1"` | Marks HMR-oriented dev session |
| `NATIVE_SDK_FRONTEND_ASSETS` | app/user | Not core API; app may force packaged assets |

## Failure modes

| Symptom | Likely cause | Check |
| --- | --- | --- |
| `MissingFrontend` / `MissingDevConfig` | No `.frontend` or no `.dev` in `app.zon` | Manifest parse; `native check` / manifest validation |
| Dev hangs then `Timeout` | Port never answers 2xx/3xx | `dev.url`, `ready_path`, command, firewall; run `npm --prefix frontend run dev` alone |
| Blank WebView / blocked navigation | Origin not allowed | Match `allowed_origins` to `zero://app` and the live dev host:port |
| Production shows empty shell | Dist missing or wrong path | Run `frontend-build`; Next must export to `frontend/out` |
| Package missing UI | Assets dir wrong | `--assets` / `frontend.dist` vs actual build output |
| Orphaned npm process | Manual kill of CLI without process group | Prefer `native dev` teardown; process groups are intentional in `dev.zig` |
| `InvalidUrl` on readiness | Non-HTTP(S) scheme | Use `http://127.0.0.1:…/`, not `ws://` |

## Verification

| Goal | Command / signal |
| --- | --- |
| Install deps | `zig build frontend-install` exits 0; `frontend/node_modules` present |
| Production shell | `zig build run` opens window with built UI (no localhost) |
| Dev + HMR | `zig build dev`; WebView loads Vite/Next URL; edit frontend → framework HMR |
| Source unit tests | Example tests assert `productionSource` root path; `src/frontend/root.zig` tests env branch |
| Package | `zig build package` (or `native package …`) includes frontend assets under configured dist |

## Next

<CardGroup>
  <Card title="Web engines" href="/web-engines">
    System WebView vs Chromium (CEF), flags, and when native-rendered UI coexists with web content.
  </Card>
  <Card title="Bridge and builtin commands" href="/bridge">
    Host-to-content bridge payloads, builtins, async dispatch, and permission boundaries.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    `native init`, `dev`, `bundle-assets`, `package`, flags, and failure modes.
  </Card>
  <Card title="Packaging" href="/packaging">
    Release binaries, asset layout, and package graph outputs for distributable apps.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix and navigation/bridge security boundaries.
  </Card>
</CardGroup>

---

## 17. Embedded app

> C embed ABI, host view controllers, mobile canvas libraries, and experimental iOS/Android host shims around the desktop-first runtime.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/17-embedded-app.md
- Generated: 2026-07-09T08:21:03.617Z

### Source Files

- `docs/src/app/embed/layout.tsx`
- `src/embed/c_api.zig`
- `src/embed/host.zig`
- `src/tooling/embedlib.zig`
- `examples/mobile-canvas/build.zig`
- `examples/ios/README.md`

---
title: "Embedded app"
description: "C embed ABI, host view controllers, mobile canvas libraries, and experimental iOS/Android host shims around the desktop-first runtime."
---

`EmbeddedApp` (`src/embed/host.zig`) wraps a `Runtime` and a `runtime.App` so a host owns the event loop: each method dispatches a platform event (`app_start`, `app_activated`, `surface_resized`, `frame_requested`, `app_shutdown`, and GPU-surface input) without the desktop platform run loop. Mobile and in-process hosts link a static library that exports the `native_sdk_app_*` C ABI (`src/embed/c_api.zig`) answered by either the fixed WebView shell (`MobileHostApp`) or a canvas `UiApp` host (`UiAppHost` via `addMobileLib`).

<Warning>
Mobile support is experimental, including embedding. iOS simulator and Android emulator paths are real and verified; APIs and tooling may still change. Desktop remains the mature surface.
</Warning>

## Architecture

Two product shapes share one lifecycle and one C symbol set:

| Shape | Library root | Host type | Presentation |
| --- | --- | --- | --- |
| Fixed WebView shell | `src/embed/c_exports.zig` (`zig build lib`) | `MobileHostApp` | Host owns UIKit/Android chrome + `WKWebView` / Android `WebView` workspace |
| Canvas `UiApp` | `src/embed/app_exports.zig` (`native_sdk.addMobileLib`) | `UiAppHost(AppDef)` | Host blits CPU-rendered RGBA8 pixels from `native_sdk_app_render_pixels` / `_damage` |

```mermaid
flowchart TB
  subgraph Host["Host process (Swift / ObjC / Kotlin / C)"]
    VC["View controller / Activity / NativeActivity"]
    Surface["WKWebView or CAMetalLayer / ANativeWindow"]
  end

  subgraph Lib["Static library lib*.a"]
    CABI["native_sdk_app_* C ABI\nMobileCApi(Host)"]
    EA["EmbeddedApp\nRuntime + App"]
    Mode{"scene option"}
    Web["MobileHostApp\nWebView shell"]
    Canvas["UiAppHost(AppDef)\ngpu_surface mobile-surface"]
  end

  VC -->|"create/start/viewport/frame\ntouch/command/stop"| CABI
  CABI --> EA
  EA --> Mode
  Mode -->|webview / c_exports| Web
  Mode -->|canvas / app_exports| Canvas
  Canvas -->|"render_pixels / damage"| Surface
  Web -->|"load source / bridge"| Surface
```

Canonical mobile surface label: `mobile-surface` (`types.mobile_gpu_surface_label`). Canvas apps must place a `gpu_surface` with that label in window 1 and set `Options.canvas_label` to the same string (`ui_host.mobile_shell_scene` is the default single-surface scene).

## Zig: EmbeddedApp

```zig
var embedded = native_sdk.embed.EmbeddedApp.init(my_app.app(), my_platform);

try embedded.start();       // .app_start
try embedded.activate();    // .app_activated
try embedded.deactivate();  // .app_deactivated
try embedded.resize(surface); // .surface_resized (+ .gpu_surface_resized when native_handle != null)
try embedded.frame();       // .frame_requested
try embedded.stop();        // .app_shutdown
```

| Method | Dispatched event / behavior |
| --- | --- |
| `init` / `initInPlace` | Build `Runtime` with the given `Platform` |
| `start` | `.app_start` |
| `activate` / `deactivate` | `.app_activated` / `.app_deactivated` |
| `resize` | `.surface_resized`; if `surface.native_handle != null`, also `.gpu_surface_resized` for window 1 / `mobile-surface` |
| `touch` / `scroll` / `key` / `text` / `ime` | `.gpu_surface_input` on window 1 / `mobile-surface` |
| `frame` | `.frame_requested` |
| `command` | `dispatchCommand` with `source = .native_view`, window 1, view label `mobile-header` |
| `stop` | `.app_shutdown` |
| `widgetSemantics` / `widgetTextGeometry` / `widgetAction` | Canvas accessibility snapshot and actions on the mobile surface |
| `textInputState` | Focus/IME intent for system keyboard show/hide |
| `audioEvent` | Platform `.audio` event from shim player reports |
| `gpuFrameState` / `canvasRevisions` | GPU frame diagnostics and revision gating for present |

Hermetic tests use `NullPlatform`:

```zig
var null_platform = native_sdk.NullPlatform.init(.{});
var embedded = native_sdk.embed.EmbeddedApp.init(.{
    .context = &state,
    .name = "embedded",
    .source = native_sdk.WebViewSource.html("<p>Embedded</p>"),
}, null_platform.platform());
try embedded.start();
```

## C ABI: native_sdk_app_*

`MobileCApi(Host)` generates the full export set; `exportMobileCApi(Host)` `@export`s every symbol under its canonical name. Every embed static library must export the names in `mobile_export_symbol_names` (`build/app.zig`).

Opaque handle: `void *` from `native_sdk_app_create()` (null on failure). Errors are recorded with `recordError`; hosts read `native_sdk_app_last_error_name`. Success flags on query APIs return `1` / `0`.

### Lifecycle

| C function | Host timing (typical) |
| --- | --- |
| `native_sdk_app_create` | Controller/activity create |
| `native_sdk_app_set_asset_root` / `set_asset_entry` | Before start (packaged frontend) |
| `native_sdk_app_set_text_measure` / `set_audio_service` / `set_image_service` | Before start (optional platform services) |
| `native_sdk_app_set_automation_dir` | Before/at start for on-device automation |
| `native_sdk_app_start` | After configuration |
| `native_sdk_app_activate` / `deactivate` | Scene active / resign; `onResume` / `onPause` |
| `native_sdk_app_viewport` or `resize` | Layout / surface size change |
| `native_sdk_app_frame` | Display link / choreographer / test tick |
| `native_sdk_app_stop` then `destroy` | Teardown |

`native_sdk_app_viewport` publishes safe-area (and host form-factor / tabs-projected flags) through the window-chrome channel, then resizes with safe + keyboard insets. Prefer it over bare `resize` on mobile so layout sees notch, home indicator, and keyboard clearance.

### Input and commands

| Function | Notes |
| --- | --- |
| `native_sdk_app_touch` | Phases: `0` down, `1` up, `2` drag, `3` cancel (`NATIVE_SDK_TOUCH_PHASE_*`) |
| `native_sdk_app_scroll` | Wheel/pan deltas in view points |
| `native_sdk_app_key` | Phases: `0` down, `1` up; modifiers bitmask |
| `native_sdk_app_text` | Committed UTF-8 text |
| `native_sdk_app_ime` | Kinds: set / commit / cancel composition |
| `native_sdk_app_command` | Stable command id string (max 128 bytes stored) |
| `native_sdk_app_text_input_state` | `active != 0` ⇒ show system keyboard; widget bounds in view points |

WebView shell examples dispatch header actions as `mobile.back` and `mobile.refresh`. Canvas chrome tabs/actions dispatch their declared shell ids (for example `tabs.counter`, `action.increment` in `examples/mobile-canvas`).

### Presentation and semantics (canvas)

| Function | Behavior |
| --- | --- |
| `native_sdk_app_gpu_frame_state` | Status, revisions, budget counters |
| `native_sdk_app_render_pixel_size` | RGBA8 byte length for a scale (`scale <= 0` → 1) |
| `native_sdk_app_render_pixels` | Full CPU reference render into caller buffer |
| `native_sdk_app_render_pixels_damage` | Incremental copy of dirty region when host retains the buffer; falls back to full render |
| `native_sdk_app_widget_semantics_*` | Accessibility snapshot nodes |
| `native_sdk_app_widget_text_geometry` | Caret/selection/composition bounds |
| `native_sdk_app_widget_action` | Focus, press, text, composition, and related actions |

`UiAppHost.frame` synthesizes the `gpu_surface_frame` a desktop display link would deliver, then runs the runtime frame. Pixel present rides the dirty-scissored path used by software desktop hosts.

### Platform services (register before start)

| Function | Role |
| --- | --- |
| `native_sdk_app_set_text_measure` | Host typographic widths; null keeps deterministic estimator |
| `native_sdk_app_set_audio_service` | Playback (+ optional `load_url` streaming); partial tables fail with `InvalidCommand` |
| `native_sdk_app_audio_event` | Async player reports: kind `0` loaded, `1` position, `2` completed, `3` failed — call between ABI entry points, not from inside a service callback |
| `native_sdk_app_set_image_service` | Decode for `fx.registerImageBytes`; without it, `UnsupportedService` / widget fallbacks |
| `native_sdk_app_set_automation_dir` | Write `snapshot.txt` and consume `command-<n>.txt` under an absolute container path |

Without audio registration, hosts set `audio_playback` / `audio_streaming` false so `fx.playAudio` fails honestly instead of using `NullPlatform`'s hermetic fake player.

### Declared platform chrome

Apps declare tabs and an optional primary action in shell metadata (`ShellConfig.chrome` / `mobileOptions().scene.chrome`). Projecting hosts (toolkit iOS host) query:

| Function | Purpose |
| --- | --- |
| `native_sdk_app_chrome_tab_count` / `_tab_at` | Declared tabs (`MobileChromeItem`: id, label, icon) |
| `native_sdk_app_chrome_primary_action` | Optional FAB-style action; `0` if none |
| `native_sdk_app_chrome_selected_tab` | Model selection index via `selected_tab_fn`, or `-1` |
| `native_sdk_app_chrome_navigation_depth` | `navigation_depth_fn` derivation for push/pop; `-1` if unused |
| `native_sdk_app_chrome_navigation_back_command` | Back command for system edge gesture |
| `native_sdk_app_chrome_icon_pixels` | Icon-vocabulary glyph → white-on-transparent RGBA8 template |
| `native_sdk_app_set_form_factor` | `0` unknown, `1` compact, `2` regular |
| `native_sdk_app_set_chrome_tabs_projected` | Host reports OS bar is live so canvas chrome can yield |

Selection and navigation state live in the model. Taps and completed back gestures re-enter through `native_sdk_app_command` so journals replay without a host. Desktop ignores projection (in-canvas chrome). Android projection of the tab bar is not complete in-tree; WebView shell chrome APIs return empty zeros for the fixed shell.

## Building the embed library

### Default WebView library

From the repository root:

```bash
zig build lib -Dtarget=aarch64-ios
# install path historically: zig-out/lib/libnative-sdk.a
```

Root module: `c_exports.zig` → `exportMobileCApi(MobileHostApp)`.

### App canvas library: addMobileLib

```zig
// examples/mobile-canvas/build.zig
native_sdk.addMobileLib(b, b.dependency("native_sdk", .{}), .{ .name = "mobile-canvas" });
// optional: .main = "src/main.zig", .scene = .canvas | .webview
```

- Canvas scene: root `app_exports.zig`, imports user module as `"app"`, host `UiAppHost(@import("app"))`.
- WebView scene: root `c_exports.zig`; app module not compiled in.
- Android targets force PIC so the archive links into a host `.so`.
- x86_64 Debug uses LLVM to avoid SysV float ABI miscompiles on viewport insets.
- Standard `addApp` / `addAppArtifacts` auto-register the `lib` step when `-Dtarget` is iOS or Android.

App module contract for canvas:

- `pub const Model`, `pub const Msg`
- `pub fn initModel() Model`
- `pub fn mobileOptions() UiApp(Model, Msg).Options` with `canvas_label = "mobile-surface"` and a matching scene
- optional `pub const features: UiAppFeatures`
- optional `on_command`, `selected_tab_fn`, `navigation_depth_fn`, `navigation_back_command`

### Target-keyed staging (CLI hosts)

Toolkit iOS/Android tiers stage archives under `.native/embed/<target-triple>/lib/lib<name>.a` (`src/tooling/embedlib.zig`) so concurrent triples do not clobber each other. Hosts run `requireArchiveClass` before link:

| Expected class | Target family |
| --- | --- |
| `macho` | iOS / Apple |
| `elf_aarch64` | Android arm64 |

Mismatch prints the staged path, found class, and rebuild line (`zig build lib -Dtarget=...`) and returns `error.EmbedLibraryMismatch` — avoiding silent ELF-into-Mach-O / Mach-O-into-ELF link failures.

## Host contract by platform event

| Host event | ABI | Example |
| --- | --- | --- |
| Create + start | `create` → configure → `start` | Swift `viewDidLoad`; Android `onCreate` |
| Activate | `activate` / `deactivate` | `SceneDelegate`; `onResume` / `onPause` |
| Size / safe area / keyboard | `viewport` then `frame` | `viewDidLayoutSubviews`; `surfaceChanged` / content rect |
| Touch | `touch` / `scroll` then `frame` | Canvas shims; Android `MotionEvent` |
| Native chrome actions | `command` | `mobile.back` / `mobile.refresh` or declared tab ids |
| Shutdown | `stop` → `destroy` | deinit / `onDestroy` |

UIKit and Android views own safe areas, orientation, system Back, and keyboard avoidance. The runtime receives metrics and commands; package-time shell metadata describes header/workspace structure. Dynamic runtime native-view mutation remains a desktop-oriented API.

## Examples

| Path | Role |
| --- | --- |
| `examples/ios` | Xcode + Swift `UIViewController`, UIKit header, `WKWebView`, `native_sdk.h`, links `libnative-sdk.a` |
| `examples/android` | Gradle/Kotlin + JNI to the same C ABI |
| `examples/mobile-canvas` | Minimal canvas `UiApp` + `addMobileLib`; hand shims under `ios/` and `android/` |
| `examples/ui-inbox` (with `-Dmobile=true`) | Larger canvas app through the same shims |

### iOS WebView shell (`examples/ios`)

```bash
zig build lib -Dtarget=aarch64-ios
mkdir -p examples/ios/Libraries
cp zig-out/lib/libnative-sdk.a examples/ios/Libraries/libnative-sdk.a
open examples/ios/NativeSdkIOSExample.xcodeproj
```

`NativeSdkDyldShim.c` supplies `_dyld_get_image_header_containing_address` via `dladdr` for panic symbolication on iOS.

### Canvas shims (`examples/mobile-canvas`)

**iOS** (`ios/run.sh`): cross-compile `zig build lib -Dtarget=aarch64-ios-simulator`, link ObjC `main.m` (CADisplayLink, CAMetalLayer blit from RGBA→BGRA, CoreText measure, UITextInput IME). Verify with screenshot non-blank; `verify_input.sh` / `verify_layout.sh` drive XCUITest + automation snapshots.

**Android** (`android/run.sh`): `zig build lib -Dtarget=aarch64-linux-android`, NativeActivity `main.c` + NDK clang → `libnative_sdk_shim.so`, aapt2/apksigner APK (no Gradle). AChoreographer pumps frames; ANativeWindow presents RGBA8 without swizzle. Soft keyboard / IME deliberately unwired (needs Java `InputConnection`); text measure uses estimator unless a host registers a callback. `minSdkVersion` 29 for TLSDESC / choreographer APIs.

Shared header for canvas shims: `examples/mobile-canvas/ios/native_sdk_app.h` (subset aligned with `src/embed/types.zig`).

### Toolkit-owned mobile apps

`native dev --target ios|android` and `native package --target ios|android` build the app embed library and wrap the SDK host (not the hand examples). Same ABI; toolkit host adds audio/image registration, full IME, and package generation. Frames still use the CPU reference renderer on mobile today; markup hot reload does not yet reach simulators/devices.

## Limits and constraints

| Limit | Value / rule |
| --- | --- |
| Command name storage | 128 bytes |
| Input text buffers | 512 bytes |
| Surface identity | Window id `1`, label `mobile-surface` |
| Command source view | `mobile-header` for embed `command` path |
| Android PIC | Required for embed objects |
| Audio without service | Declined (no silent fake player) |
| Image without service | `UnsupportedService` / fallbacks |
| Chrome projection | Model is source of truth; host is read-only projector |
| Cross-target archive | Must match object format (embedlib gate) |

## Troubleshooting

| Symptom | Check |
| --- | --- |
| `native_sdk_app_create` returns null | Host `create()` failed (invalid canvas scene / `canvas_label`, OOM) |
| Corrupt safe-area / keyboard floats on x86_64 emulator | Ensure embed lib built with LLVM workaround path for Debug |
| Host loads but symbols missing / crash on first call | Wrong triple archive; rebuild with matching `-Dtarget`; Android: confirm `elf_aarch64` not Mach-O |
| Blank surface | Call `viewport`/`resize` with non-zero size before `frame`; for canvas, present when revision changes via `render_pixels` / damage |
| Keyboard never shows (canvas) | Poll `text_input_state` after input; Android minimal NativeActivity has no IME |
| Audio “works” but no sound | Register complete audio service before `start` |
| Tab bar never appears | Host must project chrome; declaration alone is inert on non-projecting hosts |
| Automation empty | `set_automation_dir` to writable absolute path; enable host automation flag (`NATIVE_SDK_AUTOMATION` / debug property as documented by the shim) |

## Related pages

<CardGroup>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    Desktop multi-window composition and GPU surfaces vs the single mobile surface contract.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix and experimental mobile maturity notes.
  </Card>
  <Card title="Automation" href="/automation">
    Snapshots, command queue, and agent surface; mobile uses set_automation_dir.
  </Card>
  <Card title="Runtime and effects" href="/runtime-effects">
    Effect kinds and platform service seams the embed audio/image bridges implement.
  </Card>
  <Card title="Commands and keyboard shortcuts" href="/commands-shortcuts">
    Shared command routing used by native chrome and embed native_sdk_app_command.
  </Card>
  <Card title="App model" href="/app-model">
    Model / Msg / update loop driven by UiAppHost for canvas embed libraries.
  </Card>
</CardGroup>

---

## 18. Automation

> Embedded automation server: accessibility snapshots, widget driving, record/replay, deterministic screenshots, and agent command surface.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/18-automation.md
- Generated: 2026-07-09T08:22:53.933Z

### Source Files

- `tools/native-sdk/automation.zig`
- `src/runtime/automation_commands.zig`
- `src/runtime/automation_snapshot.zig`
- `src/runtime/automation_widget_dispatch.zig`
- `skill-data/automation/SKILL.md`

---
title: "Automation"
description: "Embedded automation server: accessibility snapshots, widget driving, record/replay, deterministic screenshots, and agent command surface."
---

Every Native SDK app can embed an automation server that publishes runtime state into `.zig-cache/native-sdk-automation/` and consumes a FIFO queue of `command-<n>.txt` entries. The `native automate` CLI is the agent and smoke-test surface: wait for readiness, assert on accessibility snapshots, drive retained-canvas widgets through real platform input paths, capture deterministic CPU-reference screenshots, round-trip bridge JSON, and launch session record/replay journals.

<Warning>
Automation is not browser DOM automation. It reports runtime, window, tray, audio, and widget state; drives retained-canvas (`gpu_surface`) widgets; and can reload WebViews or dispatch bridge requests. For DOM testing of the optional WebView path, use the frontend framework's tests or a browser tool against the dev server.
</Warning>

## Architecture

```mermaid
flowchart LR
  subgraph CLI["native automate"]
    Wait["wait / assert"]
    Drive["widget-* / tray / bridge"]
    Shot["screenshot"]
    Sess["record / replay"]
  end

  subgraph Dropbox[".zig-cache/native-sdk-automation/"]
    Snap["snapshot.txt\nwindows.txt\naccessibility.txt"]
    Queue["command-N.txt\n(max 8, FIFO)"]
    Art["screenshot-*.png\nbridge-response.txt\nprovenance.txt"]
  end

  subgraph Runtime["Runtime + automation.Server"]
    Pub["publish() per frame"]
    Drain["takeCommand() one per frame"]
    Dispatch["automation_widget_dispatch\nplatform events"]
  end

  Wait --> Snap
  Drive --> Queue
  Shot --> Queue
  Queue --> Drain
  Drain --> Dispatch
  Pub --> Snap
  Pub --> Art
  Sess -->|"NATIVE_SDK_SESSION_*"| Runtime
```

| Layer | Ownership |
| --- | --- |
| Dropbox directory | Created by the **running app**, never by the CLI |
| Path resolution | Relative to the CLI's **current working directory** (run from the app project root) |
| Protocol handshake | `protocol=N` in the snapshot header; CLI refuses mismatched versions |
| Liveness | `publisher_pid` checked against the live process table; dead publishers are stale |
| Queue cadence | One command consumed per presented frame; delete entry = consumption ack |

Current dropbox protocol version is **6** (queued `command-<n>.txt` entries, max depth **8**). Bump history lives in `src/automation/protocol.zig`.

## Enable automation

Build or run with the compile-time gate:

```bash
zig build run -Dplatform=macos -Dautomation=true
# or, when using the managed CLI:
native build -Dautomation=true
```

Wire the server into `Runtime.init`:

```zig
const server = native_sdk.automation.Server.init(
    io,
    ".zig-cache/native-sdk-automation",
    "My App",
);
var runtime = native_sdk.Runtime.init(.{
    .platform = my_platform,
    .automation = server,
});
```

Apps built without `-Dautomation=true` ignore automation files. The directory name passed to `Server.init` must match where the CLI looks (default `.zig-cache/native-sdk-automation`).

## Dropbox files

| File | Role |
| --- | --- |
| `snapshot.txt` | App readiness, protocol, diagnostics, windows, views, widgets, tray, audio, errors, optional `frame_profile` |
| `accessibility.txt` | Accessibility tree summary (roles and accessible names) |
| `windows.txt` | Window list: `window @w{id} "{title}" focused={bool}` |
| `command-<n>.txt` | Queued command lines; CLI claims next sequence exclusively |
| `bridge-response.txt` | Last bridge response JSON |
| `provenance.txt` | Last provenance query result |
| `screenshot-<view-label>.png` | Atomic PNG from the reference renderer |

<Note>
`label=` on a widget replaces visible text as the accessible name. Snapshot greps and screen readers see the label, not the text — do not label an element whose visible text your assertions match.
</Note>

### Snapshot header fields

The ready line is shaped like:

```text
ready=true protocol=6 frame=… commands=… runtime_uptime_ns=… dispatch_errors=… dropped_trace_records=… publisher_pid=… markup_watch=armed|off
```

| Field | Meaning |
| --- | --- |
| `protocol` | Dropbox handshake version baked into both CLI and app |
| `dispatch_errors` | Lifetime count of handler/update errors degraded without killing the app |
| `publisher_pid` | Publishing process; CLI refuses dead pids as stale |
| `markup_watch` | `armed` when hot-reload watch is active (typically Debug + `watch_path`) |

Recent degraded errors appear as indented lines:

```text
  error event=<tag> name=<ErrorName> timestamp_ns=… detail="…"
```

While `profile on` is active, a greppable `frame_profile` line lists per-stage p50/p90/max microseconds and sample counts for `rebuild`, `layout`, `reconcile`, `emit`, `a11y`, `plan`, `patch`, `encode`, `present`, `host_decode`, and `host_draw`.

## Standard workflow

<Steps>
  <Step title="Start the app with automation">
    Build/run with `-Dautomation=true` from the app project directory so the dropbox path matches the CLI cwd.
  </Step>
  <Step title="Wait for readiness">
    ```bash
    native automate wait
    ```
    Blocks until `snapshot.txt` contains `ready=true` from a live publisher.
  </Step>
  <Step title="Assert or inspect">
    ```bash
    native automate assert 'gpu_nonblank=true' 'role=button name="Reset"'
    native automate assert --absent 'error event='
    native automate snapshot
    native automate list
    ```
  </Step>
  <Step title="Drive UI and capture">
    Use widget verbs, tray actions, bridge JSON, and `screenshot` against view labels and bare widget ids from the snapshot (`#id` prints without the `#` in CLI args).
  </Step>
</Steps>

Successful command delivery prints:

```text
delivered <action> -> <absolute automation dir>
```

The CLI waits for the app to consume the queue entry. A dead or frozen app exits non-zero instead of silently dropping the command.

## CLI command surface

Entry point: `native automate <command>` (repository-built: `zig-out/bin/native automate …`).

### Session and readiness

| Command | Behavior |
| --- | --- |
| `wait` | Poll until `ready=true` with a live publisher |
| `assert [--absent] [--timeout-ms 30000] <pattern>…` | Regex match against full `snapshot.txt` (poll 100ms); `--absent` inverts |
| `list` | Print `windows.txt` after liveness check |
| `snapshot` | Print `snapshot.txt` after liveness check |
| `record --out <journal> -- <app…>` | Launch app with `NATIVE_SDK_SESSION_RECORD` |
| `replay <journal> [--verify\|--no-verify] -- <app…>` | Launch with `NATIVE_SDK_SESSION_REPLAY` (+ verify flag) |

### Window, focus, chrome

| Command | Behavior |
| --- | --- |
| `reload` | WebView reload request |
| `resize <w> <h> [scale]` | Main-window resize (+ optional scale) |
| `menu-command <id>` | Menu command event |
| `native-command <id> [view-label]` | Native view command |
| `shortcut <id>` | Shortcut command |
| `tray-action <item-id>` | Status-item dropdown row (`tray-item #id` in snapshot) |
| `focus <view-label>` | Focus a view |
| `focus-next` / `focus-previous` | Cycle visible enabled views |
| `profile on\|off` | Toggle per-stage frame timing in snapshot |

### Widget driving

Widget verbs target a `gpu_surface` view by **label** and a widget by **bare numeric id** across all open windows.

| Command | Behavior |
| --- | --- |
| `widget-action <view> <id> <action> [value]` | Semantic actions (see below) |
| `widget-click <view> <id>` | Synthetic pointer down+up (one atomic gesture batch) |
| `widget-hold <view> <id>` | Press-and-hold: down → hold timer fire → suppressed release (`on_hold`) |
| `widget-context-press <view> <id>` | Secondary click; presents context menu or immediate `on_hold` |
| `widget-context-menu <view> <id> <item-index>` | Invoke declared menu item (0-based; skips OS tracking loop) |
| `widget-drag <view> <id> <sx> <ex> [sy ey]` | Pointer drag across ratios (default y = 0.5) |
| `widget-wheel <view> <id> <delta-y>` | Wheel on interactive/scrollable targets only |
| `widget-key <view> <key> [text]` | Key to focused widget; chords like `cmd+c`, `ctrl+shift+arrowleft` |

#### `widget-action` kinds

| Action | Notes |
| --- | --- |
| `focus` | Focus target |
| `press` | Enter-key press path |
| `toggle` | Space-key toggle path |
| `increment` / `decrement` | Step keys from widget |
| `set_text` / `set-text` | Real typing path: focus, select-all, text-input event (TEA `on_input` stays consistent) |
| `set_selection` / `set-selection` | Requires value |
| `set_composition` / `commit_composition` / `cancel_composition` | IME composition path (requires set_text support) |
| `select` | Selection target |
| `drag` | Value is drag delta |
| `drop_files` / `drop-files` | Requires path value |
| `dismiss` | Dismiss path |

Unsupported actions and bad targets land in the snapshot error ring (for example `automation.widget_wheel` with `WheelTargetUnknown`, `WheelTargetNotInteractive`, `WheelTargetHasEmptyBounds`).

### Screenshots

```bash
native automate screenshot <view-label> [scale]
```

Renders the named `gpu_surface` canvas through the **deterministic CPU reference renderer** (same pixel path as Linux software presentation) to:

```text
.zig-cache/native-sdk-automation/screenshot-<view-label>.png
```

Written as temp file + rename (presence implies complete PNG).

| Property | Behavior |
| --- | --- |
| Default scale | `1` (independent of display backing scale) |
| Same-machine determinism | Unchanged scene → byte-identical PNGs |
| Layout | Live retained scene + platform text measurement (e.g. CoreText on macOS) |
| Glyph raster | Bundled faces via reference renderer (not OS font rasterizer) |
| Cross-machine | Text widths can differ with OS text metrics; compare on one machine or assert dimensions/changedness |
| WebView pixels | **Not** captured |
| OS `screencapture` | Not a substitute — may silently return wallpaper without Screen Recording permission |

### Bridge

```bash
native automate bridge '{"id":"smoke","command":"native.ping","payload":{"source":"automation"}}'
```

Origin is `zero://inline`. The app bridge policy must allow that origin or the call fails with `permission_denied`.

| Bridge error | Meaning |
| --- | --- |
| `unknown_command` | No handler or wrong name |
| `permission_denied` | Origin/permission policy |
| `handler_failed` | Handler error or invalid JSON |
| `payload_too_large` | Exceeded bridge limits |

### Provenance and markup write-back

```bash
native automate provenance <view-label> <widget-id>
native automate provenance <view-label> at <x> <y>
```

Reports authored markup location into `provenance.txt`: file, byte span, line:column, template instantiation chain, iteration keys. Zig-builder widgets report `authored=zig` (not editable via write-back).

```bash
native automate edit <view> <id> set-attr <name> <value>
native automate edit <view> <id> remove-attr <name>
native automate edit <view> <id> set-text <text...>
```

Flow: provenance → refusal ladder → checked span edit → whole-closure validation → file write. Hot reload (when `markup_watch=armed`) picks up the change; no reload command required.

Refusals (file untouched):

- Widget not markup-authored
- Disk file hash differs from what the app loaded (concurrent edit guard)
- Edit fails markup validation or would break the import closure
- App not watching markup (`watching=true` required)

## Record and replay

Session journals capture platform events, effect results, checkpoints, and screenshot marks from **launch** (init-time effects must be recorded).

```bash
native automate record --out session.journal -- ./zig-out/bin/my-app
native automate replay session.journal --verify -- ./zig-out/bin/my-app
native automate replay session.journal --no-verify -- ./zig-out/bin/my-app
```

| Environment variable | Effect |
| --- | --- |
| `NATIVE_SDK_SESSION_RECORD` | Stream journal from first dispatched event |
| `NATIVE_SDK_SESSION_REPLAY` | Headless null-platform replay of the journal |
| `NATIVE_SDK_SESSION_VERIFY` | `1` (default) verify fingerprints/screenshots; `0` skip |

Replay refuses truncated journals (unclean exit leaves no end record). Child exit code propagates; verification failures exit non-zero with mismatch reports.

## Assertions

Prefer `assert` over `snapshot | grep` chains: it polls and prints missing patterns plus a snapshot tail on timeout.

```bash
native automate assert 'gpu_nonblank=true' 'role=button name="Reset"' 'count: 0'
native automate assert --timeout-ms 10000 '4 open'
native automate assert --absent 'error event=' 'dispatch_errors=[1-9]'
```

Regex subset: literals, `.`, postfix `* + ?`, anchors `^ $`, classes `[a-z]` / `[^0-9]`, `\d \w \s` (and uppercase negations). No groups or alternation — pass multiple patterns. Quote patterns in single quotes.

## Scope

**Can verify**

- Automation-enabled app reached `ready=true`
- App name, source kind/size, window metadata
- Widget roles, names, bounds, focus, selection, scroll, tray rows, audio mirror
- Bridge round-trips and builtin command dispatch
- Retained-canvas pixels via reference screenshots
- Deterministic session replay with checkpoint verification

**Cannot verify**

- WebView/DOM pixel capture or arbitrary DOM queries
- Browser network assertions
- Exhaustive UI coverage (smoke layer, not a full E2E suite)

## Troubleshooting

| Symptom | Check |
| --- | --- |
| `wait` times out with no snapshot | App running? `-Dautomation=true`? Runner passes `automation` into `Runtime.init`? Correct cwd? |
| `no automation dir at <path>` | CLI cwd differs from app launch cwd; dir is app-created only |
| Protocol mismatch | Rebuild stale CLI or app (`native version`); both must share protocol N |
| Stale instance warning | Publisher started before newest `zig-out/bin` binary; kill leftover and relaunch |
| Command seems ignored | Confirm `delivered …` line; queue full (max 8) retries then fails loudly |
| Bridge `permission_denied` | Allow origin `zero://inline` for smoke tests |
| Wheel/click failures | Aim at interactive widget ids from snapshot; read `error event=` lines |
| Screenshot empty expectations | Confirm `gpu_surface` view label; WebView content is out of scope |
| Edit refused | Need markup-authored widget, matching file hash, validation success, `markup_watch=armed` |

```bash
# Inspect live dropbox
cat .zig-cache/native-sdk-automation/snapshot.txt
# Extra runtime tracing
zig build run -Dautomation=true -Dtrace=all
```

## CI and smoke patterns

Repository examples wire automation into smoke steps, for example:

```bash
zig build test-webview-smoke -Dplatform=macos
zig build test-writeback-smoke   # provenance + edit loop (macOS)
```

A minimal smoke loop:

1. Build with `-Dautomation=true` (and `-Djs-bridge=true` when testing bridge)
2. Start the app in a GUI-capable session (or Xvfb on Linux)
3. `native automate wait`
4. `native automate assert` on critical patterns
5. Optional: `bridge`, `screenshot`, widget verbs
6. Fail on timeout, protocol mismatch, or unexpected response

Scaffolds from `native init --full` ship a CI workflow that pairs null-platform `zig build test` with a Linux Xvfb job using `wait` / `assert` / non-empty screenshot.

## Agent surface

The shipped skill pack at `skill-data/automation/SKILL.md` is discoverable via `native skills` and describes the same command vocabulary for agents verifying a running app. Automation is provider-neutral file IPC: any local agent that can run shell commands against the project cwd can drive it without a hosted connector.

## Related pages

<CardGroup>
  <Card title="Testing" href="/testing">
    Full-loop UI tests via native test, headless truth drivers, and frame-level verification.
  </Card>
  <Card title="Testing in CI" href="/testing-ci">
    Gate scripts, platform truth drivers, and reproducible headless/hosted checks.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    native automate flags, outputs, and failure modes alongside other CLI verbs.
  </Card>
  <Card title="Agent skills" href="/agent-skills">
    How skill packs (including automation) are discovered and applied.
  </Card>
  <Card title="Bridge and builtin commands" href="/bridge">
    Bridge payloads, origins, permissions, and handlers used by automate bridge.
  </Card>
  <Card title="Runtime and effects" href="/runtime-effects">
    Effect channel behavior that session record/replay journals and feeds.
  </Card>
</CardGroup>

---

## 19. Testing

> Full-loop UI tests via native test, headless truth drivers, effect tests, and frame-level verification without a display server dependency.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/19-testing.md
- Generated: 2026-07-09T08:24:19.237Z

### Source Files

- `tests/README.md`
- `tools/linux-truth/README.md`
- `tools/windows-truth/README.md`
- `docs/src/app/testing/layout.tsx`
- `src/runtime/effects_tests.zig`

---
title: "Testing"
description: "Full-loop UI tests via native test, headless truth drivers, effect tests, and frame-level verification without a display server dependency."
---

`native test` is the app-directory entry point for the full-loop suite: it runs `zig build test --summary all` (or the CLI-synthesized graph for zero-config `app.zon` + `src/` apps), prints `native test: passed` on success, and refreshes `zig-out/model-contract.zon` for typed `native check`. Headless coverage rides `NullPlatform` and `TestHarness` — no window server — while live-truth loops under `tools/linux-truth` and `tools/windows-truth` drive real OS windows when host behavior must be proven.

## Testing tiers

| Tier | Surface | Display server? | What it proves |
|------|---------|-----------------|----------------|
| Full-loop unit | Generated / hand-written `src/tests.zig` | No | Markup → tree → typed dispatch → model → rebuild |
| Runtime harness | `TestHarness` + `NullPlatform` | No | Lifecycle, GPU frame present, automation commands, effects |
| Effect channel | Fake and real executors | No | Spawn/fetch/file/timer/audio requests, cancel, drain via `.wake` |
| Frame / pixel | Layout tree + CPU reference renderer | No | Frames, device-pixel presents, FNV-1a surface signatures |
| Live truth | `tools/linux-truth`, `tools/windows-truth` | Yes (Xvfb or real desktop) | Real window chrome, input, packaging, record/replay |
| Automation smoke | `native automate` against a live binary | Host-dependent | Snapshots, widget drive, deterministic screenshots |

Cross-package fixtures that do not belong to a single module live under `tests/` and still run via `zig build test`. Most suites live next to their Zig modules.

## `native test`

```sh
native test [dir]
```

Behavior (from the shared app verbs in `src/tooling/verbs.zig`):

1. Requires `app.zon` in the app directory (or pass the app path).
2. If the app owns `build.zig`, runs plain `zig build test --summary all`.
3. If the app is zero-config (`app.zon` + `src/` only), synthesizes the build graph under `.native/build/` and runs that with `--prefix` aimed at the app’s `zig-out/`.
4. On success prints: `native test: passed (test tally in the build summary above)`.

Forward `-D...` / `--release` flags the same way as `native build`. A missing framework checkout fails with `MissingFramework` and a hint to set `NATIVE_SDK_PATH` or use a `native` binary from the SDK checkout.

`native test` is also what keeps `native check` model-aware: a fresh `zig-out/model-contract.zon` lets check validate bindings, iterables, and message tags against `Model` / `Msg`. Without it, check degrades to structural validation and says so loudly.

<Tip>
Apps that own `build.zig` can emit the contract alone with `zig build model-contract`. Zero-config apps should prefer `native test`.
</Tip>

## Full-loop UI tests

Scaffolded apps get `src/tests.zig` that builds the real markup view, walks the widget tree, dispatches the same pointer messages the runtime would, and asserts on model + rebuilt view — no window.

```zig
var view = try canvas.MarkupView(Model, Msg).init(arena, main.app_markup);
var ui = canvas.Ui(Msg).init(arena);
const tree = try ui.finalize(try view.build(&ui, &model));

const plus = try expectByText(tree.root, .button, "+");
main.update(&model, tree.msgForPointer(plus.id, .up).?);
try testing.expectEqual(@as(i64, 1), model.count);

tree = try buildTree(arena, &model); // rebuild after every dispatch
```

Rules that fail tests when ignored:

| Rule | Behavior |
|------|----------|
| Tree is a snapshot | Rebuild after each `update` before the next press |
| Disabled controls | `msgForPointer` returns `null` — assert null, do not unwrap |
| Stable ids | Same control keeps its id across rebuilds while text may change |
| Markup sliders | Use `msgFor(id, .change)`; `msgForValue` is for Zig builder `on_value` only |
| Layout | `canvas.layoutWidgetTree` asserts real frames without presenting |

The scaffold helpers (`findByText` / `expectByText`) fail with a diagnostic when markup and the test drift, instead of a null-unwrap panic.

## TestHarness and NullPlatform

`TestHarness` is a heap-allocated headless driver: it embeds the multi-megabyte `Runtime`, so always use `create` / `destroy` (stack instances overflow test threads). Same heap rule applies to `UiApp` instances in tests.

```zig
const harness = try native_sdk.TestHarness().create(
    std.testing.allocator,
    .{ .size = geometry.SizeF.init(400, 300) },
);
defer harness.destroy(std.testing.allocator);

// Defaults: NullPlatform + BufferSink trace, dispatch_error_policy = .propagate
try harness.start(app); // app_start → surface_resized → frame_requested
// ... dispatch events, automation commands, effects ...
try harness.stop(app);  // app_shutdown
```

`start` installs the app and requests an initial frame. Tests that exercise degrade paths set `runtime.dispatch_error_policy` back to `.degrade` explicitly; the default is fail-loud.

`NullPlatform` records sources and presents without creating real windows. Enable GPU surface recording when asserting pixel presents:

```zig
harness.null_platform.gpu_surfaces = true;
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
    .label = "stream-canvas",
    .size = geometry.SizeF.init(400, 300),
    .scale_factor = 1,
    .frame_index = 1,
    .timestamp_ns = 1_000_000,
    .nonblank = true,
} });
```

Framework suites use this path for bridge policy, window reconcile, automation `widget-click`, and CPU fallback when the packet presenter is unsupported (device-resolution RGBA8 present counts, width/height, scale, byte length).

## Effect tests

Side effects only leave the process through the effects channel. Tests choose an executor:

### Fake executor (deterministic, default for app tests)

```zig
app_state.effects.executor = .fake; // before first dispatch / init_fx
try app_state.dispatch(&harness.runtime, 1, .start);

const request = app_state.effects.pendingSpawnAt(0).?;
try testing.expectEqualStrings("gh", request.argv[0]);

try app_state.effects.feedLine(stream_key, "alpha");
try app_state.effects.feedExit(stream_key, 0);
// Drain the same way live platforms do: worker → wake → update
while (harness.null_platform.takeWake()) |_| {}
try harness.runtime.dispatchPlatformEvent(app, .wake);
```

Fake-side assertions cover:

- Recorded spawn / fetch / file / clipboard / timer / audio requests
- Synthetic `feedLine` / `feedExit` / `feedResponse` / `feedFileResult` / `fireTimer` / `feedAudioEvent`
- Cancel before drain: queued lines never become Msgs; exactly one `.cancelled` exit
- Slot reuse after cancel with sticky generation filtering
- Queue overflow: drop counts are loud; over-long lines truncate with flags
- Collect mode: output + stderr tails, truncation bounds, empty payloads on cancel

### Real executor (still headless)

`effects_tests.zig` also runs real processes under `TestHarness` (POSIX-oriented; many tests `SkipZigTest` on Windows). Coverage includes streaming stdout into the model, stdin + nonzero exits, cancel of a long stream, parent `HOME`/`PATH` inheritance for children, raised line bounds, collect mode, and `spawn_failed` for unspawnable binaries. Completions still drain through platform wakes — the same path production hosts use.

<Note>
Set the fake executor before `init_fx` runs if boot spawns should be recorded rather than executed.
</Note>

## Frame-level verification

Without a display server, three layers still pin visual truth:

1. **Layout frames** — `layoutWidgetTree` / `expectLayoutFrame` for geometry.
2. **Present path** — `gpu_surface_frame` events force install + present; assert retained widget text and `NullPlatform` present counters / dimensions.
3. **Reference-renderer signatures** — `referenceSurfaceSignature` (FNV-1a over RGBA8) pins catalog and theme registers:

```zig
try testing.expectEqual(
    @as(u64, 4863232662243686658),
    support.referenceSurfaceSignature(pixels),
);
```

Vector raster tests pin curvy coverage the same way (FNV-1a over the coverage grid). Theme packs (`house`, `geist`) keep separate pinned signatures so a token or emitter change fails CI instead of silently restyling.

Pinned goldens (pixel signatures, schema fingerprints, command counts) are updated deliberately: review the rendered output or counted commands first, and keep the pin comment a self-contained description of what the value represents.

For live PNG goldens at scale 1 through the deterministic CPU reference renderer, use automation screenshots (`native automate screenshot <view-label>`) — two captures of an unchanged scene are byte-identical on the same machine. See [Automation](/automation) and [Testing in CI](/testing-ci).

## Headless truth drivers

When null-platform confidence is not enough, the repo carries live-truth loops that drive showcase apps on real hosts:

### Linux (`tools/linux-truth`)

Runs under Xvfb in a container with Zig, GTK4, WebKitGTK headers. Repo mounts read-only at `/src`; builds use container-local `/work`; artifacts land in `/out`.

```sh
tools/linux-truth/run-all.sh [image|up|sync|recon|drive|suites|all]
```

| Step | Role |
|------|------|
| `image` / `up` | Build and start the long-lived container |
| `sync` | rsync host → `/work` (never write-back) |
| `recon` | Build/launch showcase apps; snapshots, inventories, both screenshot channels |
| `drive` | Replay clicks, text, wheel, resize (including min-size clamp) |
| `suites` | Engine + example suites and webview link check on real Linux |

### Windows (`tools/windows-truth`)

Drives a real unlocked Windows 11 desktop over SSH (cmd.exe default shell). Visible-desktop work hops through `schtasks /IT`; artifacts under `%TEMP%\native-truth-out\`.

```powershell
powershell -NoProfile -File tools\windows-truth\run-all.ps1 [recon|drive|effects|record|writeback|package|all]
```

Steps cover recon snapshots, interaction drive, effect stream/cancel/clipboard, record/replay with headless verification, markup write-back + hot reload, and packaged-artifact launch.

These loops answer “what does the OS actually do?” — window styles, IME, resize floors, clipboard, packaging — without trusting the null platform.

## Framework and example suite commands

From the SDK checkout (no display server for these steps unless noted):

```bash
zig build test
zig build test-desktop
zig build test-automation-protocol
zig build test-platform-info
zig build test-examples
zig build test-examples-native    # -Dplatform=null; native CLI graph for zero-config examples
zig build test-examples-mobile
zig build test-example-<name>    # e.g. test-example-notes
```

Contributor local gate (maps your diff to the suites that cover it):

```bash
scripts/gate.sh fast [ref]   # default base: main
scripts/gate.sh full         # CI-shaped local pass
```

Opt-in GUI smokes (macOS session) live under separate build steps such as `test-webview-smoke`, `test-native-shell-smoke`, and CEF variants — they use the automation protocol against real windows. Details and workflow recipes: [Testing in CI](/testing-ci).

## Verification signals

| Command / check | Success signal | Failure signal |
|-----------------|----------------|----------------|
| `native test` | Build summary + `native test: passed` | Non-zero zig exit; no pass line |
| `zig build test -Dplatform=null` | All tests green | First failing test name + expect dump |
| Fake effect test | Pending request shape + model after `.wake` | `EffectNotFound`, wrong counts, silent drops (should not happen) |
| Pixel pin | `expectEqual` on signature | Signature moved — review render before re-blessing |
| `native check` after test | Typed contract checks | “model contract: not yet built…” if artifact missing/stale |
| Linux/Windows truth | Artifacts in `/out` or `%TEMP%\native-truth-out\` | Step script non-zero; empty inventories/screenshots |

## Common failure modes

| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| Stack overflow in test thread | `TestHarness` or `UiApp` on the stack | Heap `create` / `destroy` |
| Null unwrap on press | Disabled control or stale tree | Assert null for disabled; rebuild after dispatch |
| Effects “did nothing” | Never drained wakes | `takeWake` loop + `dispatchPlatformEvent(.wake)` |
| `feedExit` after cancel | Slot already retired with `.cancelled` | Expect `error.EffectNotFound` |
| Real spawn missing PATH/HOME | Old environ wiring (regression covered) | Ensure harness uses `std.testing.environ` (default) |
| `native test` / MissingFramework | Zero-config app without SDK path | Set `NATIVE_SDK_PATH` or use checkout `zig-out/bin/native` |
| Signature golden failed | Intentional visual change or accidental token drift | Review pixels; update pin only deliberately |
| Truth loop empty screenshots | Locked desktop (Windows) or container not up (Linux) | Unlock session; `up` + `sync` before `recon` |

## Related pages

<CardGroup>
  <Card title="Testing in CI" href="/testing-ci">
    Workflows, gate scripts, Xvfb smoke, and reproducible headless checks.
  </Card>
  <Card title="Automation" href="/automation">
    Snapshots, widget driving, deterministic screenshots, record/replay.
  </Card>
  <Card title="Runtime and effects" href="/runtime-effects">
    Effect kinds, cancellation, worker wakes, and fake-executor test surface.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    `native test`, `check`, `automate`, and related flags and failure modes.
  </Card>
  <Card title="App model" href="/app-model">
    Model, Msg, update loop wiring that full-loop tests exercise.
  </Card>
  <Card title="Contributing" href="/contributing">
    Local setup, `scripts/gate.sh`, and pre-1.0 contribution expectations.
  </Card>
</CardGroup>

---

## 20. Testing in CI

> CI workflows, gate scripts, platform truth drivers, and eval harness wiring for reproducible headless and hosted checks.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/20-testing-in-ci.md
- Generated: 2026-07-09T08:21:26.875Z

### Source Files

- `.github/workflows/ci.yml`
- `docs/src/app/testing/ci/layout.tsx`
- `scripts/gate.sh`
- `tools/linux-truth/drive.sh`
- `evals/README.md`

---
title: "Testing in CI"
description: "CI workflows, gate scripts, platform truth drivers, and eval harness wiring for reproducible headless and hosted checks."
---

Native SDK continuous integration is defined by `.github/workflows/ci.yml` (PR and `main` pushes), the local tiered gate `scripts/gate.sh`, platform smoke scripts under `.github/scripts/`, containerized or SSH-based live-truth drivers in `tools/linux-truth` and `tools/windows-truth`, and the eval harness under `evals/` (typechecked in CI; model runs stay manual). Zig is pinned at **0.16.0** via `mlugg/setup-zig@v2`. Workflow permissions are `contents: read` on CI; separate workflows handle CEF packaging and releases.

## CI jobs

| Job | Runner | What it runs |
| --- | --- | --- |
| `zig` | `ubuntu-latest` | `zig build test`, `zig build validate` |
| `macos-webview` | `macos-14` | WebView system-link + smoke, package signing, GPU dashboard/components smokes |
| `macos-gpu-perf` | `macos-14` | `zig build test-gpu-dashboard-perf` (isolated from correctness smokes) |
| `linux-webkitgtk` | `ubuntu-latest` | `zig build test-webview-system-link -Dplatform=linux` (+ GTK/WebKitGTK apt deps) |
| `windows-webview` | `windows-2022` | `zig build test-webview-system-link -Dplatform=windows` |
| `cef-platform-tooling` | `ubuntu-latest` | `zig build test-tooling` |
| `npm-package` | `ubuntu-latest` | `version:check`, `scripts:check` under `packages/native-sdk` (Node 24) |
| `native-examples` | `ubuntu-latest` | `zig build test-examples-native` (+ GTK deps) |
| `linux-canvas-smoke` | `ubuntu-latest` | `.github/scripts/linux-canvas-smoke.sh` (Xvfb + WebKitGTK) |
| `windows-canvas-smoke` | `ubuntu-latest` (30 min) | `.github/scripts/windows-canvas-smoke.sh` (Wine + Xvfb + xdotool) |
| `windows-effects-smoke` | `ubuntu-latest` (30 min) | `.github/scripts/windows-effects-smoke.sh` (Wine effects path) |
| `frontend-examples` | `ubuntu-latest` | `zig build test-examples-frontends` |
| `mobile-examples` | `ubuntu-latest` | `zig build test-examples-mobile` |
| `scaffold` | `ubuntu-latest` | `native init` / `test` / `check` / `eject` + frontend scaffold YAML parse |
| `evals-typecheck` | `ubuntu-latest` | `pnpm typecheck` in `evals/` (Node 22, pnpm 10.23.0) |
| `docs` | `ubuntu-latest` | `pnpm check` in `docs/` |

```text
.github/workflows/
  ci.yml              # PR + main (table above)
  cef-runtime.yml     # workflow_dispatch: CEF multi-platform packaging
  release.yml         # maintainer release path
.github/scripts/
  linux-canvas-smoke.sh
  windows-canvas-smoke.sh
  windows-effects-smoke.sh
scripts/gate.sh       # local affected / full gate
tools/linux-truth/    # container Xvfb live-truth
tools/windows-truth/  # real Windows desktop live-truth (SSH)
evals/                # agent authoring harness (manual runs)
```

### macOS budgets in CI

Shared `macos-14` runners are noisier than a quiet dev box. CI **widens latency budgets without relaxing correctness assertions**:

| Variable | Used by | CI value | Local default (dev) |
| --- | --- | --- | --- |
| `NATIVE_SDK_SMOKE_BUDGET_MS` | GPU dashboard/components smokes | `1500` | `150` (first-frame / automation-ready ceiling) |
| `NATIVE_SDK_PERF_BUDGET_MS` | `test-gpu-dashboard-perf` | `1500` | `300` (p90 cold first-frame) |
| `NATIVE_SDK_PERF_INPUT_BUDGET_MS` | same | `500` | `100` (p90 steady-state input) |

`macos-gpu-perf` is a **separate job** so runner noise is visible in isolation and never blocks WebView/GPU correctness smokes. Perf harness details: `scripts/perf-gpu-dashboard.sh` (defaults: 5 cold launches, 5 interactions; p90 nearest-rank).

### Canvas and effects smokes

**Linux** (`.github/scripts/linux-canvas-smoke.sh`) builds `examples/ui-inbox` with `-Dplatform=linux -Dweb-engine=system -Dautomation=true`, runs under `xvfb-run`, and asserts:

1. `ready=true` (readiness budget **90 s** on shared runners — measured ~27 s stall after EGL init)
2. `gpu_nonblank=true` and `gpu_backend=software`
3. automation `widget-click` on “Add task” → snapshot contains `4 open`
4. non-empty `screenshot-inbox-canvas.png`

Env overrides used in CI/local containers:

- `WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1` — bubblewrap needs unprivileged user namespaces; ubuntu-24.04 AppArmor otherwise kills the web process before a snapshot appears
- `GTK_A11Y=none` — avoids ~25 s GDBus a11y bus lookup under Xvfb with no session bus

**Windows canvas** (Wine on Ubuntu): same ui-inbox assertions plus real XTEST pointer/keyboard via `xdotool` (only coverage of Win32 pointer/keyboard mapping in the software path). Re-execs under Xvfb when `DISPLAY` is empty (`1280x800x24`).

**Windows effects** (Wine): drives `examples/effects-probe` — `fx.spawn` → streamed lines, `effects_wake` in the app log (not inferred from frame ticks alone), `fx.cancel` freezes the line count.

Failures dump snapshot + app log via script-local `fail()` / `diagnostics()` (scripts deliberately avoid bare `set -e` so grep misses do not silent-exit).

### Scaffold job

After `zig build`:

- Slim zero-config: `native init` → no `build.zig` / `build.zig.zon` → `native test -Dplatform=null` → `native check` → `native eject` → in-app `zig build test -Dplatform=null`
- Full frontends (`native`, `next`, `vite`, `react`, `svelte`, `vue`): `native init --frontend … --full` → `zig build test -Dplatform=null` → `native validate app.zon` → non-empty `.github/workflows/ci.yml` must parse as YAML

### Related workflows

- **`cef-runtime.yml`**: manual multi-platform CEF prepare/release (not on every PR).
- **`release.yml`**: maintainer packaging/distribution (see packaging and updates docs).

## Local gate (`scripts/gate.sh`)

The local gate mirrors CI-shaped checks without requiring every remote job. Every step runs even after failures; exit code is non-zero if any step failed. Steps are timed and summarized.

```bash
scripts/gate.sh fast [base-ref]                  # affected-only (default base: main)
scripts/gate.sh full [base-ref] [--all] [--perf] # full local CI-shaped set
```

Diff set: `git diff --name-only` against `git merge-base <base-ref> HEAD`, plus untracked files.

### Path → suite mapping (`fast`)

| Changed paths | Steps |
| --- | --- |
| `src/**`, `build.zig`, `build.zig.zon`, `build/**`, `tools/**`, `tests/**`, `assets/**` | Root `zig build test` + `validate`, **all** example suites (frontends, native, mobile), render-bench ratchet (macOS) |
| `src/platform/macos/**` | Above + `cef-host-link` (`test-webview-cef-link` / Chromium host compile+link; skips with warning if CEF layout missing) |
| `examples/<name>/**` | That example’s suite only (`test-example-<name>`, or in-dir `zig build test` for `browser`/`hello`/`webview`; mobile group → `test-examples-mobile`) |
| `docs/**` only | Docs `pnpm` install + `check` only |
| Other (README, `.github`, packages, scripts, skills, changelog…) | Root suites only |

Example suite names are **derived from** `addExampleTestStep("test-example-…")` in `build.zig` so the gate never drifts from the build graph.

### Full tier

Always: root test + validate, all example suites, CEF host link step, markup check over every `examples/**/*.native` file, render-bench ratchet on macOS.

macOS-only smokes: `test-gpu-surface-smoke`, `test-gpu-dashboard-smoke`, `test-gpu-components-smoke`, `test-webview-smoke`, `test-native-shell-smoke`, `test-canvas-preview-smoke`, `test-writeback-smoke`.

| Flag / env | Effect |
| --- | --- |
| `--perf` | `test-gpu-dashboard-perf` (macOS; opt-in, load-sensitive) |
| `--all` | Force docs check even if `docs/` unchanged |
| `NATIVE_SDK_SKIP_BENCH_CHECK=1` | Skip render-bench budget ratchet on a noisy box |

**Render-bench ratchet** (`bench-check`): `zig build bench-render -Doptimize=ReleaseFast -- --check tools/bench-render-budgets.txt`. Headless, in-process, calibrated on Apple Silicon; trips on order-of-magnitude regressions (~1.3× headroom), not machine noise. Runs by default on framework diffs (`fast`) and always on `full` (macOS).

Docs check installs with frozen lockfile and uses `NEXT_DIST_DIR=.next-gate` so it never corrupts a running docs dev server’s `.next` cache.

<Check>
Run `scripts/gate.sh fast` before finishing a change. It maps your diff to the suites that cover it; full CI still runs the remote matrix on push/PR.
</Check>

## Platform truth drivers

Live-truth loops build and drive **real** showcase binaries (not only `-Dplatform=null`) to record layout, input, resize clamps, multi-window, and screenshots.

### Linux (`tools/linux-truth`)

Container image bundles Zig, GTK4, WebKitGTK headers, and Xvfb. Repo mounts read-only at `/src`; builds go to volume `/work`; artifacts land in `/out`.

```bash
tools/linux-truth/run-all.sh [image|up|sync|recon|drive|suites|all]
```

| Step | Role |
| --- | --- |
| `image` / `up` | Build image; start long-lived container `native-sdk-linux-truth` |
| `sync` | rsync `/src` → `/work` (never writes back); refuses if container mounts a different checkout |
| `recon` | Build showcase apps (`-Dplatform=linux -Dweb-engine=system -Dautomation=true`), launch under Xvfb, dump snapshot/widgets/views + engine + X11 screenshots |
| `drive` | Per-app scenarios: clicks, set-text, wheel, resize, min-size clamp (`drive.sh`) |
| `suites` | `zig build test`, `validate`, `test-webview-system-link -Dplatform=linux`, `test-examples-native` |

Automation transport: apps with `-Dautomation=true` publish `.zig-cache/native-sdk-automation/snapshot.txt` and consume ordered `command-*.txt` entries (deleted on ack). Helpers live in `lib.sh` (`launch_app`, `send`, `widget_id`, engine `shot` vs X `xshot`).

Copy artifacts: `docker cp native-sdk-linux-truth:/out <dest>`.

### Windows (`tools/windows-truth`)

Real Windows 11 desktop over SSH (not Wine). Requires unlocked interactive desktop; GUI work hops through `schtasks /IT`. Artifacts under `%TEMP%\native-truth-out\`.

```powershell
powershell -NoProfile -File tools\windows-truth\run-all.ps1 [recon|drive|effects|record|writeback|package|all]
```

Steps cover recon, interaction drive, effects/clipboard, record/replay, markup write-back, and packaged-artifact launch — deeper than CI’s Wine smokes.

## Eval harness and CI boundary

`evals/` grades agent authoring of Native apps (scaffold + skill delivery + deterministic graders + optional LLM judge). **CI only typechecks** the harness (`evals-typecheck` → `pnpm typecheck`). Real eval runs are local/manual — no model calls in CI.

```bash
cd evals && pnpm install
pnpm typecheck                 # what CI runs
pnpm eval --list
pnpm eval --dry-run            # scaffold + graders, no model
pnpm eval --sandbox            # Linux lane in Vercel Sandbox microVMs
pnpm eval templates-settings-app
```

| Surface | Notes |
| --- | --- |
| Live snapshots | Default needs macOS GUI; use `--skip-live` or `--sandbox` elsewhere |
| Auth for real runs | `AI_GATEWAY_API_KEY` (gateway-routed agent); sandbox also needs Vercel OIDC or `VERCEL_TOKEN` + team/project IDs |
| Outputs | `results/<timestamp>/<case>/` (`result.json`, transcript, screenshots); workspaces under `.workspaces/` (gitignored) |
| Permissions | Default allowlist for `zig` / `native-sdk` / file tools; deny `evals/cases/**` and `evals/results/**` so agents cannot read grading configs |

Cases live under `evals/cases/<name>/eval.json`. Prompts describe requirements only; graders assert markup grammar, tests, and automation snapshot roles.

## App-generated CI (`native init --full`)

Scaffolds with `--full` (and web-frontend templates) write `.github/workflows/ci.yml` into the **app** repo. Zero-config slim scaffolds omit CI; copy recipes when needed.

Typical generated shape uses `NATIVE_SDK_PATH` (default `../native-sdk`) so `build.zig.zon` path deps resolve after cloning the framework onto the runner.

| Tier | Command / environment | Proves |
| --- | --- | --- |
| Logic | `zig build test -Dplatform=null` | Model/Msg/update, markup, layout without display |
| Linux smoke | build `-Dplatform=linux -Dautomation=true` under Xvfb + `native automate` | Real window, GPU surface, widget semantics |
| macOS | framework CI / optional local GUI runners | AppKit windows + same automate surface |

Deterministic screenshots: `native automate screenshot <view-label>` uses the CPU reference renderer at scale 1 (byte-identical for unchanged scenes on the same machine). Prefer goldens from the same runner image across OS versions.

```bash
rm -rf .zig-cache/native-sdk-automation   # never assert against a prior run
native automate wait
native automate assert 'gpu_nonblank=true' 'role=button name="Reset"'
native automate assert --absent 'error event=' 'dispatch_errors=[1-9]'
native automate screenshot main-canvas
```

`automate assert` polls until every regex matches (or `--absent` until all are gone). Default timeout 30000 ms; CI-friendly exit codes and snapshot tail on failure.

## Environment and knobs reference

| Variable | Where | Purpose |
| --- | --- | --- |
| `NATIVE_SDK_SMOKE_BUDGET_MS` | GPU smokes | First-frame / automation-ready latency budget (ms) |
| `NATIVE_SDK_PERF_BUDGET_MS` | GPU perf | p90 cold first-frame budget |
| `NATIVE_SDK_PERF_INPUT_BUDGET_MS` | GPU perf | p90 input latency budget |
| `NATIVE_SDK_PERF_LAUNCHES` / `NATIVE_SDK_PERF_INTERACTIONS` | perf script | Sample counts (default 5 / 5) |
| `NATIVE_SDK_SKIP_BENCH_CHECK` | `gate.sh` | Skip render-bench ratchet |
| `WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS` | Linux canvas smoke | WebKit under restricted runners |
| `GTK_A11Y` | Linux canvas smoke | Avoid a11y bus stall under Xvfb |
| `WINEPREFIX` / `WINEDEBUG` | Wine smokes | Isolated prefix; quiet Wine logs |
| `AI_GATEWAY_API_KEY` | evals real runs | Model gateway |
| `NATIVE_SDK_EVAL_MODEL` / `NATIVE_SDK_EVAL_JUDGE_MODEL` | evals | Override coder/judge gateway slugs |
| `NATIVE_SDK_PATH` | generated app CI | Framework checkout relative path |

## Failure modes and verification signals

| Symptom | Likely cause | Signal |
| --- | --- | --- |
| Canvas smoke: snapshot never `ready=true` | Sandbox/a11y env, cold-start stall | Script diagnostics dump snapshot + app log; readiness 90 s on Linux CI |
| GPU smoke budget fail on CI only | Shared runner noise | Raise `NATIVE_SDK_*_BUDGET_MS` in **CI env only**; keep local defaults strict |
| Gate `cef-host-link` skipped with warning | No CEF layout on disk | `zig build && ./zig-out/bin/native cef install` |
| linux-truth sync refuses | Container mounts another checkout | Recreate with `run-all.sh up` from the intended tree |
| Wine smoke flaky input | Focus/window manager under Xvfb | windows-canvas dumps X window list + snapshot |
| evals CI green but authoring broken | CI never runs models | Run `pnpm eval` / `--sandbox` locally |
| Docs gate fails only after docs edit | Path-gated by design | `pnpm --dir docs check` or `gate.sh full --all` |

**Green signals:** gate summary `all steps green`; smoke scripts print `PASS: …`; CI job matrix green on PR; scaffold job leaves valid YAML workflows; `pnpm typecheck` clean under `evals/`.

## Next

<CardGroup>
  <Card title="Testing" href="/testing">
    Headless full-loop tests, TestHarness, and local smoke step names without the CI matrix.
  </Card>
  <Card title="Automation" href="/automation">
    Snapshot protocol, widget drive, screenshots, and `native automate` commands used by smokes and truth drivers.
  </Card>
  <Card title="Contributing" href="/contributing">
    Local setup, agent guidance, and when to run the gate before landing a change.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    `native test`, `check`, `automate`, and build flags used in scripts and scaffolds.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix and how Linux/Windows live-truth relate to platform claims.
  </Card>
  <Card title="Web engines" href="/web-engines">
    System WebKitGTK, CEF host link checks, and Chromium install paths that CI and the gate touch.
  </Card>
</CardGroup>

---

## 21. Packaging

> From ReleaseFast binaries to distributable app bundles: assets, build graph outputs, and packaging layout expectations.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/21-packaging.md
- Generated: 2026-07-09T08:21:57.719Z

### Source Files

- `docs/src/app/packaging/layout.tsx`
- `src/tooling/assets.zig`
- `src/tooling/buildgraph.zig`
- `packages/native-sdk/scripts/build-binaries.sh`
- `examples/calculator/README.md`

---
title: "Packaging"
description: "From ReleaseFast binaries to distributable app bundles: assets, build graph outputs, and packaging layout expectations."
---

`native build` installs a **ReleaseFast** executable at `zig-out/bin/<name>` (from `app.zon` metadata). `native package` then copies that binary, mirrors assets, generates icons and platform metadata, and writes a platform-specific artifact under `zig-out/package/`. Implementation lives in `src/tooling/package.zig` (`createPackage`); zero-config apps reach the same packager through a generated build graph in `.native/build/` without ejecting.

## Pipeline

```text
app.zon + src/ [+ assets/]
        │
        ▼
 native build  ──►  zig-out/bin/<name>     (ReleaseFast default)
        │              ▲
        │              │ --binary / auto-discover
        ▼              │
 native package ───────┘
        │
        ├── macos   → zig-out/package/<name>.app
        ├── linux   → zig-out/package/<name>-linux/
        ├── windows → zig-out/package/<name>-windows/
        ├── ios     → generated Xcode host project (+ Libraries/libnative-sdk.a)
        └── android → generated host project (+ optional <name>-debug.apk)
```

Zero-config apps do **not** require `native eject` to package. `native build` / `native package` synthesize `.native/build/{build.zig,build.zig.zon}` via `buildgraph.ensureGeneratedBuild` when no owned `build.zig` exists, point Zig at that graph with `--prefix` set to the app’s `zig-out/`, and keep the framework dependency relative to the generated build root.

Ejected or scaffolded apps that already own `build.zig` get an additional graph step: `zig build package` runs the `native` CLI artifact with `--binary` bound to the built executable and `--optimize` set to the app’s real mode (default **ReleaseFast** for the app module).

## Commands

### Build then package

```bash
native build
native package --target macos
```

Success signals:

- `native build: built zig-out/bin/<name> (ReleaseFast)`
- `info[package.binary]: using zig-out/bin/<name>` (when auto-discovering)
- diagnostic `package.created`: `created <target> artifact at <path>`

Override optimize only when the packaged binary was not built as ReleaseFast:

```bash
native package --target macos --binary zig-out/bin/myapp --optimize Debug
```

### CLI surface

```bash
native package \
  [--target macos|windows|linux|ios|android] \
  [--manifest app.zon] \
  [--output path] \
  [--binary path] \
  [--assets path] \
  [--optimize ReleaseFast] \
  [--web-engine system|chromium] \
  [--cef-dir path] \
  [--cef-auto-install] \
  [--signing none|adhoc|identity] \
  [--identity name] \
  [--entitlements path] \
  [--team-id id] \
  [--archive]
```

| Flag | Default | Role |
| --- | --- | --- |
| `--target` | `macos` | Package target (`PackageTarget`) |
| `--manifest` | `app.zon` | App metadata source |
| `--output` | `zig-out/package/<name>.app` (macOS) or `zig-out/package/<name>-<target>` | Artifact root |
| `--binary` | Desktop: `zig-out/bin/<name>[.exe]`; iOS/Android: builds embed lib | Executable or `libnative-sdk.a` |
| `--assets` | `frontend.dist` when set, else `assets` | Tree copied into the package |
| `--optimize` | `ReleaseFast` | Label on report/archive names; must match the binary |
| `--signing` | `none` | macOS signing mode |
| `--archive` | off | Create `.dmg` / `.zip` / `.tar.gz` after the package |

Shortcuts (same packager, fixed defaults):

```bash
native package-windows [--output path] [--binary path]
native package-linux   [--output path] [--binary path]
native package-ios     [--output path] [--binary path]
native package-android [--output path] [--binary path]
```

Mobile shortcuts default output under `zig-out/mobile/{ios,android}` and still use ReleaseFast for the embed library when `--binary` is omitted.

### Asset-only bundling

```bash
native bundle-assets [app.zon] [assets-dir] [output-dir]
# defaults: app.zon → frontend.dist or assets → zig-out/assets
```

`src/tooling/assets.zig` walks the source tree, copies every file, and writes `asset-manifest.zon` with `id`, `bundle_path`, `source_path`, `byte_len`, SHA-256 `hash`, and optional `media_type`. A missing assets directory yields an empty manifest (not a hard failure).

## Build graph outputs

| Path | Producer | Contents |
| --- | --- | --- |
| `.native/build/build.zig` | `ensureGeneratedBuild` | Regenerated `native_sdk.addApp(...)` graph (`app_root = "../.."`) |
| `.native/build/build.zig.zon` | same | Relative `native_sdk` path dependency + package name |
| `zig-out/bin/<name>` | `native build` / `zig build` install | ReleaseFast app binary (desktop) |
| `zig-out/package/...` | `native package` | Platform artifact |
| `package-manifest.zon` | `writeReport` inside the artifact | `.artifact`, `.target`, `.version`, `.app_id`, `.executable`, `.optimize`, `.web_engine`, `.signing`, `.asset_count`, frontend/capabilities |
| `Contents/Resources/signing-plan.txt` | macOS only | Signing mode requested (written **before** codesign) |

Framework resolution for generated graphs (`NATIVE_SDK_PATH`, then CLI location → checkout / npm package / split `@native-sdk/cli` + platform binary) is the same path `native init|dev|build|test` use.

App modules default to **ReleaseFast** when no `-Doptimize` is set (`build/app.zig` `app_optimize`); tests stay Debug unless overridden. That split is why packaging stamps `ReleaseFast` by default even though `native dev` is a Debug loop.

## app.zon fields that drive packages

```zig
.{
    .id = "com.example.myapp",
    .name = "myapp",
    .display_name = "My App",
    .version = "1.0.0",
    .icons = .{"assets/icon.png"},
    .platforms = .{ "macos", "linux" },
    .file_associations = .{},
    .url_schemes = .{},
    .web_engine = "system",
    .cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
    .frontend = .{ .dist = "dist", .entry = "index.html", .spa_fallback = true },
}
```

| Field | Packaging use |
| --- | --- |
| `id` | Bundle ID, desktop ID, log/state prefix; iOS normalizes `_` → `-` |
| `name` | Executable / artifact base name |
| `display_name` | Menu bar, Finder, desktop entry, DMG volume name |
| `version` | `CFBundleShortVersionString` / package report |
| `icons` | Single-source icon pipeline (see below) |
| `file_associations` / `url_schemes` | macOS plist types, Linux MIME/desktop, Windows registry script |
| `frontend` | Asset root becomes `frontend.dist`; macOS layout uses `Resources/<dist>` |
| `web_engine` / `cef` | System vs Chromium; Chromium packages CEF into the macOS bundle |

Validate before packaging:

```bash
native validate app.zon
native doctor --manifest app.zon --strict
```

## Assets layout expectations

Default non-frontend packaging:

1. Source tree: project-relative `assets/` (or `--assets`).
2. macOS host resolves relative runtime paths against `Contents/Resources`, so the packager mirrors the app-relative assets path into the bundle (default `Contents/Resources/assets/`). A path like `assets/music/track.mp3` names the same file in `native dev` and in the installed app.
3. Absolute or `..`-escaping `--assets` paths have no honest app-relative subpath and land flat under `Contents/Resources`.
4. Frontend apps: assets land under `Contents/Resources/<frontend.dist>` (and `resources/<dist>` on Linux/Windows) so `zero://app/` paths such as `/assets/app.js` resolve without `file://` URLs.
5. Everything under the assets directory ships; keep large optional data out of that tree if it should not be in the package.

iOS uses a project-root `Assets/` folder (not `Resources/`) so the generated Xcode project does not look like a deep macOS-style bundle during `xcodebuild archive`. Android mirrors assets to `assets/native-sdk/` for first-launch install onto the device.

## Platform artifacts

### macOS (`.app`)

Default output: `zig-out/package/<name>.app`.

:::files
myapp.app/
  Contents/
    MacOS/<name>
    Info.plist
    PkgInfo
    Frameworks/                    # Chromium only
      Chromium Embedded Framework.framework/
    Resources/
      AppIcon.icns                 # or prebuilt .icns basename
      assets/…                     # or <frontend.dist>/
      package-manifest.zon
      signing-plan.txt
:::

- `LSMinimumSystemVersion` is `11.0`.
- `CFBundleName` / `CFBundleDisplayName` use the display name; the executable name stays `metadata.name`.
- File associations → `CFBundleDocumentTypes`; URL schemes → `CFBundleURLTypes`.
- Chromium: `copyMacosCefRuntime` copies CEF into `Contents/Frameworks/…` after `cef.ensureLayout`.
- Signing: `--signing none|adhoc|identity` (identity needs `--identity`; optional `--entitlements`). Full codesign/notarization workflow is on [Code signing](/code-signing).

`--archive` runs `hdiutil create -format UDZO` → sibling  
`<name>-<version>-macos-<optimize>.dmg`.

### Linux (install tree)

Default output: `zig-out/package/<name>-linux/`.

| Path | Role |
| --- | --- |
| `bin/<name>` | Executable |
| `resources/…` | Bundled assets (or frontend dist) |
| `share/applications/<name>.desktop` | Desktop entry |
| `share/icons/hicolor/<size>x<size>/apps/app-icon.png` | Generated sizes |
| `share/mime/packages/<name>.xml` | When file associations exist |
| `package-manifest.zon` | Package report |

`--archive` → `<name>-<version>-linux-<optimize>.tar.gz` via `tar czf`.

Installer formats (AppImage, Flatpak) are not generated; the README in the tree marks that as future work.

### Windows (directory artifact)

Default output: `zig-out/package/<name>-windows/`.

| Path | Role |
| --- | --- |
| `bin/<name>.exe` | Executable |
| `resources/…` | Assets |
| `app-icon.ico` | Multi-size generated or prebuilt |
| `install/register-file-types.ps1` | Per-user `HKCU\Software\Classes` when associations/schemes exist |
| `package-manifest.zon` | Package report |

Early support: directory layout only; MSI/installer generation is not implemented.  
`--archive` → `.zip` via `zip -r`.

### iOS (experimental host project)

`native package --target ios` builds the device-slice embed library when `--binary` is omitted (`info[package.ios]: building the embed static library …`), then emits a complete toolkit-owned Xcode project:

- `<name>.xcodeproj` + shared scheme (headless `xcodebuild` ready)
- `Host/` — UIKit host sources + generated `Info.plist`
- `Libraries/libnative-sdk.a` — app as embed static library
- `Assets.xcassets` + `Assets/` — icons and bundled assets
- `package-manifest.zon`, `README.md`

Code signing stays manual (`DEVELOPMENT_TEAM` / Xcode), analogous to notarization. Simulator loop is `native dev --target ios`, not this package output.

### Android (experimental host project + debug APK)

`native package --target android` builds `aarch64-linux-android` embed lib when needed, emits:

- `Host/` activity + JNI bridge
- `AndroidManifest.xml`, `res/` launcher icons
- `assets/native-sdk/`
- `Libraries/libnative-sdk.a`
- `<name>-debug.apk` when SDK/NDK + JDK are present (aapt2, javac, d8, zipalign, apksigner; debug keystore under `~/.native/android/`)

Missing toolchain prints a notice and still emits the project without the APK. Store signing remains manual.

## Icons

One square source in `.icons` (typically `assets/icon.png` 1:1, ideally 1024×1024, or `assets/icon.svg`) drives every platform:

| Platform | Output |
| --- | --- |
| macOS | Generated `AppIcon.icns` (platform mask/margins) |
| Windows | Multi-size `app-icon.ico` |
| Linux | hicolor PNG set (`app-icon.png`) |
| iOS / Android | Asset catalog / mipmap from the same pipeline |

Precedence: prebuilt `.icns` (macOS) or `.ico` (Windows) in `.icons` ships **untouched** under its own basename / as `app-icon.ico`. Missing or unusable sources fall back to the SDK default icon embedded in the packager so packages are never text placeholders. `native validate` and packaging share the same square/decodable checks.

Regenerate the SDK default family (maintainers):

```bash
zig build generate-icon
```

## Frontend and Chromium

Frontend apps set `.frontend` in `app.zon`. Packaging defaults `--assets` to `frontend.dist`. Build the frontend before package when dist is not already present.

Chromium on macOS:

```bash
native cef install --version <pinned-version>
# app.zon: .web_engine = "chromium", .cef = .{ .dir = "third_party/cef/macos", … }
native build
native package --target macos
```

CLI/build `-Dweb-engine` / `--web-engine` and `-Dcef-dir` / `--cef-dir` are temporary overrides. Chromium packaging validates targets: **macos** (and experimental ios/android paths) accept Chromium; **windows/linux** return `UnsupportedWebEngine` for Chromium packages.

Layout pin:

```bash
zig build test-package-cef-layout -Dplatform=macos
```

## Archives and reports

With `--archive` (desktop targets only):

| Target | Tool | Suffix |
| --- | --- | --- |
| macOS | `hdiutil` UDZO | `.dmg` |
| Windows | `zip -r` | `.zip` |
| Linux | `tar czf` | `.tar.gz` |

Archive basename: `<name>-<version>-<target>-<optimize><suffix>` next to the package. Failures print a warning and leave the package directory intact.

Every successful package writes `package-manifest.zon` with the optimize label, engine, signing mode, and asset count for CI and human inspection.

## Failure modes

| Symptom | Likely cause |
| --- | --- |
| `error: app.zon not found` | Not in app root; pass `--manifest` |
| `warning[package.no-binary]` | Run `native build` first or pass `--binary` |
| Empty/missing executable in package | Same as above (desktop still creates skeleton layout) |
| `InvalidIconSource` | Non-square PNG / unsupported SVG |
| `UnsupportedWebEngine` | Chromium on windows/linux package target |
| CEF launch/package gaps | Version/layout mismatch; run `native doctor` + `test-package-cef-layout` |
| Android project without APK | No SDK/NDK/JDK or no embed library |
| iOS archive missing bundle id | Do not rename generated `Assets/` → `Resources/` |

## SDK CLI binaries (not app packages)

`packages/native-sdk/scripts/build-binaries.sh` cross-compiles the **`native` CLI** (`zig build cli -Doptimize=ReleaseSmall`) into npm platform packages and flat `zig-out/release/native-sdk-*` assets with `CHECKSUMS.txt`. That path distributes the toolchain, not end-user apps. See [Updates and package distribution](/updates-distribution).

## Related pages

<CardGroup>
  <Card title="Code signing" href="/code-signing">
    Codesign modes, framework copy order, and macOS signing constraints after `native package`.
  </Card>
  <Card title="Updates and package distribution" href="/updates-distribution">
    npm multi-platform layout, version sync, and `@native-sdk` binary distribution.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    `native build`, `native package`, flags, and failure modes.
  </Card>
  <Card title="Frontend projects" href="/frontend">
    Managed frontend dist output that packaging mirrors into the bundle.
  </Card>
  <Card title="Web engines" href="/web-engines">
    System vs Chromium, CEF install, and when the package grows a Frameworks tree.
  </Card>
  <Card title="Embedded app" href="/embed">
    Embed ABI and host libraries that iOS/Android package projects link.
  </Card>
</CardGroup>

---

## 22. Code signing

> Codesign tooling, framework and native binary copy steps, and signing constraints for macOS and related package pipelines.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/22-code-signing.md
- Generated: 2026-07-09T08:22:03.605Z

### Source Files

- `docs/src/app/packaging/signing/layout.tsx`
- `src/tooling/codesign.zig`
- `packages/native-sdk/scripts/copy-framework.js`
- `packages/native-sdk/scripts/copy-native.js`
- `src/tooling/assets.zig`

---
title: "Code signing"
description: "Codesign tooling, framework and native binary copy steps, and signing constraints for macOS and related package pipelines."
---

`native package --target macos` builds a `.app` tree, then optionally signs it through `src/tooling/codesign.zig` (`signAdHoc` / `signIdentity`). Signing is macOS-only in the package path. Default mode is `none`. Identity signing enables Hardened Runtime (`--options runtime`). Notarization is a separate host-tool step; the package command does not call `notarytool`.

## Signing modes

| Mode | Flag value | Behavior |
| --- | --- | --- |
| None | `none` (default) | Writes `signing-plan.txt` as unsigned; no `codesign` |
| Ad-hoc | `adhoc` (alias `ad-hoc`) | `codesign --sign - --force --deep` |
| Identity | `identity` | `codesign --sign <identity> --force --deep --options runtime` plus optional `--entitlements` |

```bash
native package --target macos --signing adhoc
native package --target macos --signing identity \
  --identity "Developer ID Application: Your Name" \
  --entitlements assets/native-sdk.entitlements
```

### Package flags

<ParamField body="--signing" type="none \| adhoc \| identity" default="none">
Signing mode. Invalid values fail CLI parse.
</ParamField>

<ParamField body="--identity" type="string">
Required for successful `identity` mode. Missing identity writes an unsigned plan and skips codesign.
</ParamField>

<ParamField body="--entitlements" type="path">
Passed only on identity sign. Example starter: `assets/native-sdk.entitlements`.
</ParamField>

<ParamField body="--team-id" type="string">
Accepted into `SigningConfig` and stored on the package options. Not consumed by `runSigning` or automatic notarization; pass the team id yourself to `notarytool`.
</ParamField>

## macOS package order (seal constraints)

`createMacosApp` finishes every sealed path **before** `runSigning`. Anything written into `Contents/` after a successful codesign breaks the resource seal (`codesign --verify --strict` reports “file added”; Gatekeeper can show “damaged”).

```text
createMacosApp
├── Contents/MacOS/<name>     binary (optional --binary)
├── Contents/Info.plist
├── Contents/Resources/       assets via assets.bundle + icons
├── package-manifest.zon      includes .signing = mode
├── Contents/Frameworks/      CEF only if web_engine=chromium
└── runSigning
    ├── write signing-plan.txt  (before codesign)
    └── codesign --deep …
```

| Stage | Path / owner | Must finish before codesign? |
| --- | --- | --- |
| App binary | `Contents/MacOS/` | Yes |
| Assets | `assets.zig` → `Contents/Resources/` | Yes |
| CEF framework | `copyMacosCefRuntime` → `Contents/Frameworks/Chromium Embedded Framework.framework` | Yes |
| Signing plan | `Contents/Resources/signing-plan.txt` | Written **before** success path; rewritten only on failure |
| Codesign | Host `/usr/bin/codesign` via shell | Last step |

<Warning>
Do not post-process the `.app` after signing (extra files under `Contents/`, re-copy CEF, edit Info.plist). Rebuild and re-sign instead.
</Warning>

### Command shapes (`codesign.zig`)

| API | Emitted command (simplified) |
| --- | --- |
| `signAdHoc` | `codesign --sign - --force --deep <app>` |
| `signIdentity` | `codesign --sign <id> --force --deep --options runtime [--entitlements <path>] <app>` |
| `buildZipCommand` | `ditto -c -k --keepParent <app> <app>.zip` |
| `buildNotarizeSubmitCommand` | `xcrun notarytool submit <zip> --team-id … [--apple-id …] [--password @keychain:…] --wait` |
| `buildStapleCommand` | `xcrun stapler staple <app>` |

`runShell` treats non-zero exits (including missing `codesign`, exit 127) as failure so callers do not claim a signature that does not exist. On package failure, the mode still soft-continues: the plan is rewritten to “unsigned” and packaging does not abort.

### Signing plan contents

| Mode / outcome | `Contents/Resources/signing-plan.txt` |
| --- | --- |
| `none` | `signing=none` / `unsigned local package` |
| `adhoc` success | `signing=adhoc` / `ad-hoc signed` |
| `adhoc` fail | `signing=adhoc` / `codesign --sign - failed; bundle is unsigned` |
| `identity` success | `signing=identity` / `signed with <name>` |
| `identity` missing id | `signing=identity` / `no identity provided; bundle is unsigned` |
| `identity` fail | `signing=identity` / `codesign failed; bundle is unsigned` |

`package-manifest.zon` also records `.signing = none|adhoc|identity` (requested mode, not post-sign verification).

## Ad-hoc vs identity vs Gatekeeper

| Signature | Typical use | Gatekeeper |
| --- | --- | --- |
| Ad-hoc (`-`) | Local / CI seal checks | Valid seal; quarantine downloads still get “cannot check for malicious software”. Local copies without quarantine often open with no dialog. |
| Developer ID + notarize | Distribution | No malware dialog when notarization staples successfully. |
| Broken seal after sign | Defect | “Damaged” / move to Trash — not normal ad-hoc behavior. |

Verification pin:

```bash
zig build test-package-signing
# packages with --signing adhoc, then:
# codesign --verify --strict --deep zig-out/package/native-sdk-signing-verify.app
# greps signing-plan for "ad-hoc signed"
# skips cleanly when codesign is absent (non-macOS hosts)
```

Doctor (`native doctor` on macOS) probes `/usr/bin/codesign`, `xcrun notarytool`, and `/usr/bin/hdiutil`.

## Notarization

Library helpers exist (`codesign.notarize`: zip with `ditto` → `notarytool submit --wait` → `stapler staple`). The `package` CLI does **not** call them.

`zig build notarize` runs host CLI `package` with `--signing identity` (and current web-engine/CEF flags). It does **not** invoke `notarytool` itself. After a signed package exists:

```bash
native package --target macos --signing identity \
  --identity "Developer ID Application: Your Name" \
  --entitlements assets/native-sdk.entitlements

# optional zip (or use ditto yourself)
ditto -c -k --keepParent zig-out/package/your-app.app zig-out/package/your-app.app.zip

xcrun notarytool submit zig-out/package/your-app.app.zip \
  --apple-id "you@example.com" \
  --team-id "TEAMID" \
  --password "@keychain:AC_PASSWORD" \
  --wait

xcrun stapler staple zig-out/package/your-app.app
```

DMG after a signed app:

```bash
zig build dmg   # framework repo helper; needs zig-out/package/… .app first
# or
hdiutil create -volname "Your App" -srcfolder zig-out/package/your-app.app \
  -ov -format UDZO zig-out/package/your-app.dmg
```

## Chromium / CEF packages

When `--web-engine chromium` (or equivalent), packaging copies `Chromium Embedded Framework.framework` into `Contents/Frameworks` **before** codesign. Deep sign is intended to cover nested Mach-O in that tree.

```bash
native cef install --version <pinned-version>
zig build
native package --target macos --signing identity \
  --identity "Developer ID Application: Your Name" \
  --web-engine chromium --cef-dir third_party/cef/macos
```

If Gatekeeper rejects a Chromium app: confirm the framework is under `Contents/Frameworks`, rebuild after any CEF version change, and re-sign the final tree (do not patch Frameworks after signing).

## Entitlements

Starter file `assets/native-sdk.entitlements`:

- `com.apple.security.cs.allow-unsigned-executable-memory` = true  
- `com.apple.security.network.client` = true  

Customize per app needs and pass `--entitlements` on identity packages. Separate tooling (for example `tools/guest-mac`) may ship its own entitlements and ad-hoc sign at build time for Virtualization; that path is independent of `native package`.

## Other platforms

| Target | Signing in Native tooling |
| --- | --- |
| macOS app | `none` / `adhoc` / `identity` via package |
| Windows / Linux desktop artifacts | No codesign step in `package.zig` |
| iOS package | Complete Xcode project; code signing manual (`DEVELOPMENT_TEAM`, `CODE_SIGN_IDENTITY`, or Xcode). `CODE_SIGNING_ALLOWED=NO` for unsigned verification archives. |
| iOS sim dev | Ad-hoc: `codesign --force --sign -` on the sim bundle |
| Android package | Debug APK via `apksigner` + debug keystore when SDK tools present; **store** keys remain manual |

## SDK npm copy pipeline (related packaging)

App codesign is separate from how `@native-sdk/cli` stages its own payload for publish. These scripts matter when you maintain release artifacts:

| Script | When | What |
| --- | --- | --- |
| `packages/native-sdk/scripts/copy-framework.js` | `npm prepack` | Mirrors repo `src/`, `build/`, `skills/`, `skill-data/`, plus `build.zig`, `build.zig.zon`, `app.zon`, `LICENSE` into the package so offline `native init` / `native dev` work |
| `packages/native-sdk/scripts/copy-native.js` | After `zig build` / `build:native` | Copies `zig-out/bin/native` → `packages/native-sdk/bin/native-sdk-<os>-<arch>[.exe]` (linux musl vs gnu detection) |
| `packages/native-sdk/scripts/build-binaries.sh` | Multi-target release | Cross-builds CLI to `packages/native-sdk/npm/<platform>/bin/` and `zig-out/release/` with `CHECKSUMS.txt` — **no codesign step** on published CLI binaries |

`check-framework-sync.js` fails the package scripts check if the mirrored tree drifts from the repo root.

## Failure modes and checks

| Symptom | Likely cause | Check |
| --- | --- | --- |
| Plan says unsigned after `adhoc`/`identity` | `codesign` missing, identity wrong, or non-zero exit | `native doctor`; run `codesign` manually; inspect stderr from package |
| Gatekeeper “damaged” | File written under `Contents/` after sign | `codesign --verify --strict --deep app.app`; rebuild package |
| Identity package unsigned | Omitted `--identity` | Plan text: `no identity provided` |
| Notarization not run by package | By design | Run `notarytool` / `stapler` yourself |
| Chromium app fails distribution checks | CEF missing or signed without framework | Confirm Frameworks layout; re-package then re-sign |

```bash
codesign --verify --strict --deep zig-out/package/your-app.app
codesign -dv --verbose=4 zig-out/package/your-app.app
cat zig-out/package/your-app.app/Contents/Resources/signing-plan.txt
```

## Related pages

<CardGroup cols={2}>
  <Card title="Packaging" href="/packaging">
    Bundle layout, assets, multi-target package outputs, and DMG expectations.
  </Card>
  <Card title="Updates and package distribution" href="/updates-distribution">
    npm multi-platform packages, version sync, and binary distribution for @native-sdk.
  </Card>
  <Card title="Web engines" href="/web-engines">
    CEF layout and when Chromium ships inside the signed .app.
  </Card>
  <Card title="Platform support and security" href="/platform-security">
    Host capability matrix and which platforms own signing.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    `native package` flags including signing options.
  </Card>
  <Card title="Testing in CI" href="/testing-ci">
    Gate scripts and package signing verification steps.
  </Card>
</CardGroup>

---

## 23. Updates and package distribution

> Update channels, npm multi-platform package layout, version sync checks, and binary distribution for @native-sdk packages.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/23-updates-and-package-distribution.md
- Generated: 2026-07-09T08:22:22.260Z

### Source Files

- `docs/src/app/updates/layout.tsx`
- `docs/src/app/packages/layout.tsx`
- `packages/native-sdk/README.md`
- `packages/native-sdk/scripts/check-version-sync.js`
- `packages/native-sdk/scripts/build-binaries.sh`

---
title: "Updates and package distribution"
description: "Update channels, npm multi-platform package layout, version sync checks, and binary distribution for @native-sdk packages."
---

The Native SDK splits distribution into two surfaces: **packaged-app update channels** declared in `app.zon` as `UpdateConfig` (`feed_url`, `public_key`, `check_on_start`), and **CLI/SDK delivery** through nine npm packages under `@native-sdk/*` — one meta package (`@native-sdk/cli`) plus eight platform binary packages selected via `optionalDependencies`. The CLI binary is staged by `packages/native-sdk/scripts/build-binaries.sh`, checksummed for GitHub Releases, and resolved at runtime by `packages/native-sdk/bin/native.js` with no install scripts.

## App update channels

`app.zon` carries an optional `.updates` block typed as `UpdateConfig` in the app manifest. Manifest validation runs `validateUpdates` on every full validate pass.

```zig
.updates = .{
    .feed_url = "https://example.com/releases/native-sdk-feed.json",
    .public_key = "base64-ed25519-public-key",
    .check_on_start = true,
},
```

### Fields

| Field | Type | Default | Constraints |
| --- | --- | --- | --- |
| `feed_url` | `?[]const u8` | `null` | When set: exact `http://` / `https://` URL, or a trailing-`*` prefix pattern (`https://…*`, `http://…*`, or `*`). Empty / invalid → `InvalidUrl` |
| `public_key` | `?[]const u8` | `null` | When set: must be non-empty (`MissingRequiredField` if `len == 0`) |
| `check_on_start` | `bool` | `false` | Whether the app should check for updates during startup |

Defaults are empty/null so apps without an update feed validate cleanly. The reserved config is for **discovery and signature material**, not silent auto-install: apps surface checks in their own UI, verify signatures before applying artifacts, and keep platform install behavior explicit.

<Warning>
Setting `public_key` to `""` fails validation. Omit the field or provide a non-empty key string.
</Warning>

## npm multi-platform layout

Install the meta package only:

```bash
npm install -g @native-sdk/cli
```

Install runs **no scripts**. Package managers resolve exactly one of the eight optional platform packages for the host OS/CPU/libc. All nine packages share one exact version (currently stamped from `packages/native-sdk/package.json`).

### Package inventory

| Package | Role | Platform selectors |
| --- | --- | --- |
| `@native-sdk/cli` | `native` shim (`bin/native.js`), SDK source payload, exact pins in `optionalDependencies` | — |
| `@native-sdk/cli-darwin-arm64` | Prebuilt `native` | `os: darwin`, `cpu: arm64` |
| `@native-sdk/cli-darwin-x64` | Prebuilt `native` | `os: darwin`, `cpu: x64` |
| `@native-sdk/cli-linux-arm64-gnu` | Prebuilt `native` | `os: linux`, `cpu: arm64`, `libc: glibc` |
| `@native-sdk/cli-linux-x64-gnu` | Prebuilt `native` | `os: linux`, `cpu: x64`, `libc: glibc` |
| `@native-sdk/cli-linux-arm64-musl` | Prebuilt `native` | `os: linux`, `cpu: arm64`, `libc: musl` |
| `@native-sdk/cli-linux-x64-musl` | Prebuilt `native` | `os: linux`, `cpu: x64`, `libc: musl` |
| `@native-sdk/cli-win32-arm64` | Prebuilt `native.exe` | `os: win32`, `cpu: arm64` |
| `@native-sdk/cli-win32-x64` | Prebuilt `native.exe` | `os: win32`, `cpu: x64` |

Platform packages set `files: ["bin"]` and `preferUnplugged: true`. Do not install a platform package directly; each README points installers at `@native-sdk/cli`.

### What the meta package ships

`@native-sdk/cli` `files` include the dispatcher, type stubs, and the mirrored SDK payload apps build against:

- `bin/native.js` — platform dispatcher
- `src/`, `build/`, `build.zig`, `build.zig.zon`, `app.zon` — framework graph for offline `native init` / `native dev`
- `skills/`, `skill-data/` — agent skill packs
- `native-sdk.d.ts`, `README.md`, `LICENSE`

`prepack` runs `scripts/copy-framework.js`, which mirrors `src`, `build`, `skills`, `skill-data`, `build.zig`, `build.zig.zon`, `app.zon`, and `LICENSE` from the repo root into the package before publish.

### Binary resolution

`bin/native.js` maps host `os` / `arch` / musl detection to a package name, then:

1. `require.resolve('@native-sdk/cli-<platform>/bin/native[.exe]')`
2. Fallback: repo-local legacy path under `packages/native-sdk/bin/native-sdk-<platform>[.exe]` (via `scripts/copy-native.js` after `zig build`)

On success it sets `NATIVE_SDK_PATH` to the meta package root when `src/root.zig` is present (user-set `NATIVE_SDK_PATH` wins), then `spawn`s the binary with inherited stdio and forwarded `SIGINT` / `SIGTERM` / `SIGHUP`.

```text
npm install -g @native-sdk/cli
        │
        ├─ @native-sdk/cli          (shim + SDK source)
        └─ @native-sdk/cli-<host>   (one optionalDep binary)
                    │
                    ▼
              bin/native.js  ──exec──►  native CLI
                    │
                    └── NATIVE_SDK_PATH → package root (if not set)
```

### Failure modes

| Symptom | Cause | Fix |
| --- | --- | --- |
| `No native binary found… Expected package: @native-sdk/cli-…` | Install with `--no-optional` / `--omit=optional` | Reinstall with optional deps enabled |
| `Unsupported platform: …` | Host OS/arch outside the eight targets | Use a supported host or build from source |
| Binary not executable (non-Windows) | Missing `+x` | Dispatcher attempts `chmod 755`; otherwise `chmod +x` the resolved path |

## Version sync

**Source of truth:** `packages/native-sdk/package.json` → `version`.

`npm run version:sync` (`scripts/sync-version.js`) stamps that version into:

1. `tools/native-sdk/main.zig` — `const version = "…";` (what `native version` prints)
2. Every `packages/native-sdk/npm/*/package.json` → `version`
3. Every `optionalDependencies` pin on the meta package (exact match, same version)

`npm run version:check` (`scripts/check-version-sync.js`) verifies all of the above plus that each npm directory’s `name` is `@native-sdk/cli-<dir>` and appears in `optionalDependencies`. On failure it exits non-zero and prints:

```text
Run "npm run version:sync" in packages/native-sdk to fix.
```

CI runs `version:check` (and `scripts:check`) before publish so a half-bumped release cannot ship. npm’s `version` lifecycle script also runs `version:sync` and stages the stamped files for commit.

```bash
# After bumping packages/native-sdk/package.json
npm --prefix packages/native-sdk run version:sync
npm --prefix packages/native-sdk run version:check
```

## Binary build and GitHub assets

`packages/native-sdk/scripts/build-binaries.sh` cross-compiles the CLI for all eight targets with `zig build cli -Dtarget=… -Doptimize=ReleaseSmall` and stages:

| Destination | Naming |
| --- | --- |
| `packages/native-sdk/npm/<key>/bin/native[.exe]` | npm platform package payload |
| `zig-out/release/native-sdk-<platform>[.exe]` | flat GitHub release assets |

Release asset names:

- `native-sdk-darwin-arm64`, `native-sdk-darwin-x64`
- `native-sdk-linux-arm64`, `native-sdk-linux-x64`
- `native-sdk-linux-musl-arm64`, `native-sdk-linux-musl-x64`
- `native-sdk-win32-arm64.exe`, `native-sdk-win32-x64.exe`
- `CHECKSUMS.txt` (SHA-256 of the assets above)

Pass a subset of npm keys to build fewer targets, e.g. `bash packages/native-sdk/scripts/build-binaries.sh darwin-arm64`.

Repo-local single-host pack (not for publish):

```bash
npm --prefix packages/native-sdk run build:native
# zig build + copy-native.js → packages/native-sdk/bin/native-sdk-<host>
```

## Release and publish pipeline

Maintainer release prep is manual (version bump + changelog markers). On push to `main`, `.github/workflows/release.yml`:

1. **check-release** — compares `packages/native-sdk/package.json` version to `npm view @native-sdk/cli version`. Mismatch → publish path. Same version but missing GitHub assets → rebuild/upload assets only.
2. **github-release** — runs `build-binaries.sh`, extracts notes between `<!-- release:start -->` / `<!-- release:end -->` in `CHANGELOG.md`, creates/updates tag `v<version>`, uploads `zig-out/release/*` with `--clobber`.
3. **publish** — re-checks version/scripts sync; downloads release assets and verifies `CHECKSUMS.txt`; stages binaries into `npm/*/bin/`; publishes **platform packages first**, then `@native-sdk/cli`, each with `npm publish --provenance --access public`. Already-published `name@version` entries are skipped.

Publishing uses npm trusted publishing (OIDC) under environment `Release` — no npm token secret. Each of the nine packages must have a trusted publisher for repository `vercel-labs/zero-native`, workflow `release.yml`, environment `Release`. Missing OIDC config fails `npm publish` for that package before upload.

There is no separate npm dist-tag channel matrix in-repo: the release gate is **version equality** against the registry, not `latest`/`canary` tags.

## Operations cheat sheet

| Task | Command / location |
| --- | --- |
| Install CLI | `npm install -g @native-sdk/cli` |
| Print CLI version | `native version` |
| Stamp versions after bump | `npm --prefix packages/native-sdk run version:sync` |
| Verify version consistency | `npm --prefix packages/native-sdk run version:check` |
| Cross-build all CLI binaries | `npm --prefix packages/native-sdk run build:binaries` |
| Mirror SDK into package (prepack) | `node packages/native-sdk/scripts/copy-framework.js` |
| Override SDK root | `NATIVE_SDK_PATH=/path/to/checkout` |
| App update feed config | `app.zon` → `.updates` |

## Related pages

<CardGroup>
  <Card title="Installation" href="/installation">
    Install @native-sdk/cli, host prerequisites, and success signals for a working binary.
  </Card>
  <Card title="Packaging" href="/packaging">
    From ReleaseFast app binaries to distributable app bundles and asset layout.
  </Card>
  <Card title="Code signing" href="/code-signing">
    Codesign tooling and signing constraints for macOS package pipelines.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    native commands including version, package, doctor, and build.
  </Card>
  <Card title="Contributing" href="/contributing">
    Local setup, release prep, and changelog fragment merge.
  </Card>
</CardGroup>

---

## 24. CLI reference

> native init, dev, check, test, build, automate, doctor, and skills commands with flags, outputs, and failure modes.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/24-cli-reference.md
- Generated: 2026-07-09T08:22:31.169Z

### Source Files

- `src/tooling/dev.zig`
- `src/tooling/doctor.zig`
- `src/tooling/buildgraph.zig`
- `packages/native-sdk/README.md`
- `README.md`

---
title: "CLI reference"
description: "native init, dev, check, test, build, automate, doctor, and skills commands with flags, outputs, and failure modes."
---

The `native` CLI is the control plane for Native SDK apps. The entrypoint is `tools/native-sdk/main.zig` (version string `0.4.0`); npm ships it as `@native-sdk/cli` with a Node dispatcher (`packages/native-sdk/bin/native.js`) that execs the per-platform binary and sets `NATIVE_SDK_PATH` to the package that carries SDK source. App verbs (`dev`, `build`, `test`) either drive an owned `build.zig` or synthesize a zero-config graph under `.native/build/` via `src/tooling/buildgraph.zig` and `src/tooling/verbs.zig`.

## Install surface

```bash
npm install -g @native-sdk/cli
native version
native --help
```

| Signal | Meaning |
| --- | --- |
| `native version` on stdout | `native 0.4.0 (commit <sha>, automation protocol v6)` — scripts parse this line |
| `native` / `native help` / no args | Prints usage; **exit 1** when no command was given, **exit 0** for explicit help |
| Unknown command | `unknown command: …` + usage, **exit 1** |

Platform binaries live in optional deps such as `@native-sdk/cli-darwin-arm64`. The dispatcher refuses to start when no binary matches the host OS/arch.

## Command inventory

| Command | Role |
| --- | --- |
| `init` | Scaffold `app.zon` + `src/` (+ frontend shell when requested) |
| `dev` | Debug build + run (markup hot reload); optional frontend server or mobile targets |
| `build` | ReleaseFast binary → `zig-out/bin/<name>` |
| `test` | App test suite via `zig build test --summary all` |
| `check` | Fast markup + `app.zon` validation (model contract when present) |
| `automate` | Drive a running automation-enabled app (dropbox IPC) |
| `doctor` | Host / WebView / CEF / tool probes |
| `skills` | List/print shipped agent skill packs |
| `markup` | Per-file check, dump, LSP |
| `eject` | Own `build.zig` / `build.zig.zon`, or eject one catalog component |
| `validate` | Schema-check `app.zon` only |
| `package` / `package-*` | Distributable layouts |
| `bundle-assets` | Copy frontend/assets into build output |
| `cef` | CEF install/path/doctor helpers |
| `version` | Print CLI version + automation protocol |

Every verb accepts `--help` / `-h` and exits **0** with that verb’s usage. Unknown flags print usage and exit **1** (they never fall through into a real run).

## Shared behavior

### App directory

App verbs resolve relative paths against the process cwd (`assets/`, markup watch, `.zig-cache/native-sdk-automation`). Pass an optional directory or `cd` first:

```bash
native dev path/to/app
native check .
```

Missing directory → `cannot enter app directory …` / `MissingAppDirectory`.

### Zero-config vs ejected builds

```text
app.zon + src/ only          build.zig present (ejected / examples)
        │                              │
        ▼                              ▼
  ensureGeneratedBuild            zig build (owned graph)
  .native/build/build.zig
        │
        ▼
  zig build --build-file … --prefix <app>/zig-out
```

Framework root resolution (`buildgraph.resolveFrameworkRoot`):

1. `NATIVE_SDK_PATH` (npm wrapper sets this)
2. Walk ancestors of the `native` executable (checkout `zig-out/bin/native`, package `bin/native`, npm split `cli` sibling)

No framework → teaching error: set `NATIVE_SDK_PATH` or use an SDK-local/npm `native` binary.

### Zig toolchain

`dev` / `build` / `test` call `toolchain.resolveZig` (pinned **Zig 0.16.0**):

| Source | Order |
| --- | --- |
| `NATIVE_SDK_ZIG` | Absolute override |
| PATH `zig` | Used when version-compatible with the pin |
| Managed install | Under `NATIVE_SDK_HOME` or `~/.native/toolchains/zig-0.16.0/` |

First managed download prompts for consent unless `--yes`. Decline → `DownloadDeclined` (exit 1). Incompatible PATH zig prints the pin and falls through to managed install.

### Flag forwarding

`dev`, `build`, and `test` forward `-D…` and `--release…` to `zig build` unchanged. Default optimize when none is forwarded:

| Verb | Default |
| --- | --- |
| `dev` | `-Doptimize=Debug` (hot reload + teaching diagnostics require Debug) |
| `build` | `-Doptimize=ReleaseFast` |
| `test` | no default optimize flag; always runs step `test` with `--summary all` |

### Expected failures (no Zig return-trace)

`failVerb` maps teaching failures to silent **exit 1**: `MissingManifest`, `MissingFramework`, `ZigUnavailable`, `DownloadDeclined`, `UnsupportedPlatform`, `ChecksumMismatch`, `ZigBuildFailed`, `InvalidManifest`, `MarkupCheckFailed`, `HostCompileFailed`, `SimulatorUnavailable`, `SimulatorCommandFailed`.

---

## `native init`

```bash
native init [path] [--frontend <native|next|vite|react|svelte|vue>] [--framework <sdk path>] [--full]
```

<ParamField body="path" type="string" default=".">
Destination directory. App name defaults to basename of path (or cwd when path is `.`).
</ParamField>
<ParamField body="--frontend" type="native|next|vite|react|svelte|vue" default="native">
Scaffold shape: pure native markup app, or a WebView shell with a managed frontend.
</ParamField>
<ParamField body="--framework" type="path">
SDK checkout used as the path dependency. Must contain `src/root.zig`. Default: same resolution as build verbs.
</ParamField>
<ParamField body="--full" type="boolean" default="false">
`Shape.full` scaffold (extra editor/config files) vs slim default.
</ParamField>

**Success**

```text
created Native SDK app at my_app (native)

Next steps:
  cd my_app
  native dev
```

For non-native frontend or `--full`, next steps suggest `zig build run` instead of `native dev`.

**Failures**

| Condition | Exit |
| --- | --- |
| Framework path invalid / unlocatable | 1 + error about `--framework` or binary location |
| Invalid `--frontend` | 1 |
| Flag typo | 1 + usage (does **not** scaffold an app named after the flag) |

---

## `native dev`

```bash
native dev [dir] [--yes] [--url url] [--command "npm run dev"] [--timeout-ms n] [-D... zig build flags]
native dev [dir] --target ios|android [--device name] [--yes] [-D...]
native dev --binary path [--manifest app.zon] [--url url] [--command "..."] [--timeout-ms n]
```

### Desktop (default)

1. Requires `app.zon` in the app dir.
2. Resolves zig, builds Debug (unless optimize forwarded).
3. **No frontend.dev in manifest:** `zig build run` (or generated equivalent). Process group owned so Ctrl-C / CLI death kills the app tree.
4. **Frontend with `frontend.dev`:** build binary only, then `dev.zig` starts the dev-server command, waits until the URL answers HTTP 2xx/3xx, sets env, runs the binary.

Environment injected for frontend dev:

| Variable | Value |
| --- | --- |
| `NATIVE_SDK_FRONTEND_URL` | Override or manifest `dev.url` |
| `NATIVE_SDK_MODE` | `dev` |
| `NATIVE_SDK_HMR` | `1` |

Stdout markers:

```text
native dev: building and running <name> (Debug) — hot reload arms in Debug builds
native dev: app exited
native dev: frontend dev flow failed (<error>)
native dev: `zig build` step failed (exit code N)
```

### Flags

<ParamField body="--yes" type="boolean">
Consent to managed Zig download without a prompt.
</ParamField>
<ParamField body="--url" type="string">
Override frontend ready URL.
</ParamField>
<ParamField body="--command" type="string">
Space-split override for the frontend dev server argv.
</ParamField>
<ParamField body="--timeout-ms" type="u32">
How long to wait for the dev server HTTP ready probe (default from manifest `dev.timeout_ms`).
</ParamField>
<ParamField body="--target" type="ios|android">
Experimental mobile loop: build embed lib, install/launch simulator or emulator, stream logs.
</ParamField>
<ParamField body="--device" type="string">
Simulator/emulator device name for mobile targets.
</ParamField>
<ParamField body="--binary" type="path">
Legacy path: skip build; only run frontend-server + shell against a prebuilt binary.
</ParamField>

### Failure modes

| Error | Cause |
| --- | --- |
| `MissingManifest` | No `app.zon` |
| `MissingFramework` | Cannot resolve SDK root for generated builds |
| `ZigBuildFailed` | Compile/link/`zig build` non-zero |
| `MissingFrontend` / `MissingDevConfig` | `--binary` / frontend path without `frontend.dev` |
| `Timeout` | Dev server never became ready |
| `InvalidUrl` | Non-http(s) ready URL |

---

## `native build`

```bash
native build [dir] [--yes] [-D... zig build flags]
```

Runs `zig build` with default `-Doptimize=ReleaseFast` when no optimize/`--release` flag is forwarded. Artifacts land under `zig-out/` (generated builds force `--prefix <app-cwd>/zig-out`).

**Success**

```text
native build: built zig-out/bin/<name> (ReleaseFast)
```

**Failures:** same manifest/framework/zig/`ZigBuildFailed` paths as `dev`.

---

## `native test`

```bash
native test [dir] [--yes] [-D... zig build flags]
```

Runs `zig build test --summary all`. Passing runs print:

```text
native test: passed (test tally in the build summary above)
```

Also refreshes `zig-out/model-contract.zon` when the app’s build graph includes the model-contract step — that artifact powers typed binding checks in `native check`.

---

## `native check`

```bash
native check [dir] [--strict]
```

No full app link: walks `src/**/*.native`, validates markup, validates `app.zon`.

| Mode | Behavior |
| --- | --- |
| Without model contract | Structural markup + vocabulary checks; prints a note to run `native test` (or `zig build model-contract` if ejected) |
| With fresh `zig-out/model-contract.zon` | Typed checks: bindings, iterables, message tags, expression types vs Model/Msg; unused model state as warnings |
| `--strict` | Warnings promoted to errors → `MarkupCheckFailed` |

**Success**

```text
checked N markup file(s)[ against the model contract] and app.zon
```

**Failures**

| Condition | Result |
| --- | --- |
| No `app.zon` | `MissingManifest` |
| Markup diagnostics | `MarkupCheckFailed` (parser/validator messages already printed) |
| Invalid manifest | `InvalidManifest` |
| `--strict` with warnings | `MarkupCheckFailed` after promotion line |

Per-file path: `native markup check <file.native>… [--strict]`. Editor integration: `native markup lsp` (stdio LSP). Intermediate form: `native markup dump <file.native> [--out doc.nsui]`.

---

## `native automate`

Drives a **running** app built with automation enabled (`-Dautomation=true`). IPC is file-based under `.zig-cache/native-sdk-automation/` (protocol **v6**). The CLI never creates the dropbox; a missing dir means the app is not publishing.

```bash
native automate <command>
```

### Session launch

```bash
native automate record --out session.journal -- ./zig-out/bin/my-app
native automate replay session.journal [--verify|--no-verify] -- ./zig-out/bin/my-app
```

Sets `NATIVE_SDK_SESSION_RECORD` or `NATIVE_SDK_SESSION_REPLAY` (+ `NATIVE_SDK_SESSION_VERIFY=1|0`) and execs the app. Non-zero app exit fails the command.

### Live dropbox commands

| Command | Purpose |
| --- | --- |
| `wait` | Block until `snapshot.txt` has `ready=true` from a live publisher |
| `list` | Print `windows.txt` (requires live snapshot) |
| `snapshot` | Print `snapshot.txt` accessibility/state dump |
| `assert [--absent] [--timeout-ms 30000] <regex>…` | Poll snapshot until patterns match (or are absent); max 32 patterns |
| `screenshot <view-label> [scale]` | Deterministic PNG via reference renderer |
| `reload` / `resize <w> <h> [scale]` | Runtime control |
| `widget-click` / `widget-action` / `widget-hold` / `widget-drag` / `widget-wheel` / `widget-key` | Drive retained-canvas widgets |
| `widget-context-press` / `widget-context-menu` | Context menu paths |
| `menu-command` / `native-command` / `shortcut` / `tray-action` | Command routing entry points |
| `focus` / `focus-next` / `focus-previous` | Focus control |
| `profile on\|off` | Frame timing in subsequent snapshots |
| `bridge <request-json>` | Host↔content bridge round-trip → `bridge-response.txt` |
| `provenance <view> <id\|at x y>` | Authorship spans for a live widget |
| `edit <view> <id> set-attr\|remove-attr\|set-text …` | Minimal-diff write-back into markup (requires hot-reload watch) |

Command delivery uses exclusive `command-<n>.txt` queue slots; success prints `delivered <action> -> <dir>` only after the app consumes the entry (~one presented frame). Full/stale queues time out ~10s with teaching errors.

### Assert / wait failure signals

- Timeout names missing/present patterns and prints a `snapshot.txt` tail
- Stale publisher (dead `publisher_pid`) or protocol skew refuses before false passes
- No automation dir: run from the app cwd; app must be automation-built

### Edit refusals (never write the file)

- Provenance not `authored=markup`
- App not watching (`MarkupOptions.watch_path`)
- On-disk hash ≠ loaded hash (concurrent edit)
- Checked span edit or whole-closure validation failure

---

## `native doctor`

```bash
native doctor [--strict] [--manifest app.zon] [--web-engine system|chromium] [--cef-dir path] [--cef-auto-install]
```

Prints a host report: OS/display, GPU software note, and probes.

| Check id (examples) | What it probes |
| --- | --- |
| `zig` | `zig version` on PATH |
| `log-path` | Resolved runtime log file (`NATIVE_SDK_LOG_DIR` overrides) |
| `manifest` | Optional: validate path from `--manifest` |
| `webview-system` | WKWebView / WebKitGTK 6 / WebView2 by OS |
| `webview-chromium` | CEF layout when chromium resolved; unsupported on Windows CEF host today |
| `codesign` / `notarytool` / `hdiutil` | macOS packaging tools |
| `app-icon` | Built-in icon pipeline availability |
| `ios-static-lib` / `android-static-lib` | Documented embed build triples |

`--strict` + any problem status → `DoctorProblems` (exit 1). Without `--strict`, problems are reported but the command succeeds.

Invalid flags → `InvalidArguments`.

---

## `native skills`

```bash
native skills list
native skills get <name> [--full]
native skills get --all [--full]
```

Discovers `SKILL.md` under `skills/` and `skill-data/` near the package root (or `NATIVE_SDK_SKILLS_ROOT`).

| Skill name | Role |
| --- | --- |
| `core` | Shared foundation, WebView shells, packaging, bridge |
| `native-ui` | Markup + Model/Msg/update authoring |
| `automation` | Running-app automation and verification |
| `native-sdk` | Discovery stub (`hidden: true`; omitted from `list`) |

`list` prints `name\tdescription` for non-hidden skills. `get` writes full `SKILL.md` to stdout; `--full` appends `references/` and `templates/` files. Unknown name → exit 1. Missing skill roots near the binary → exit 1.

---

## Related commands (brief)

### `native eject`

```bash
native eject [dir]
native eject component <name> [dir]
```

Writes owned `build.zig` + `build.zig.zon` **once** (`AlreadyEjected` if present). Component eject copies one library composite into `src/components/` (did-you-mean on typos).

### `native validate [app.zon]`

Manifest schema only; non-ok diagnostic → exit 1. File missing → teaching path error.

### `native package`

```bash
native package [--target macos|windows|linux|ios|android] [--output path] [--binary path]
  [--assets path] [--web-engine system|chromium] [--cef-dir path] [--cef-auto-install]
  [--signing none|adhoc|identity] [--identity name] [--entitlements path] [--team-id id]
  [--optimize ReleaseFast] [--archive]
```

Defaults binary discovery to `zig-out/bin/<name>` after `native build`. Shortcuts: `package-windows`, `package-linux`, `package-ios`, `package-android`.

### `native cef`

```bash
native cef install|path|doctor [--dir path] [--version version] [--source prepared|official] [--force]
```

Chromium/CEF layout helpers used when packaging or doctoring chromium engines.

---

## Environment variables

| Variable | Effect |
| --- | --- |
| `NATIVE_SDK_PATH` | Framework checkout (source + build graph) |
| `NATIVE_SDK_ZIG` | Zig executable override |
| `NATIVE_SDK_HOME` | Managed toolchain root (default `~/.native`) |
| `NATIVE_SDK_SKILLS_ROOT` | Override skills discovery root |
| `NATIVE_SDK_LOG_DIR` | Runtime log directory (doctor `log-path`) |
| `NATIVE_SDK_FRONTEND_URL` / `NATIVE_SDK_MODE` / `NATIVE_SDK_HMR` | Set by `native dev` frontend flow |
| `NATIVE_SDK_SESSION_RECORD` / `REPLAY` / `VERIFY` | Set by `automate record|replay` |

---

## Typical workflows

<Steps>
  <Step title="Scaffold and run">
    `native init my_app && cd my_app && native dev` — Debug binary, hot-reload `.native` views.
  </Step>
  <Step title="Fast validation loop">
    Edit markup → `native check` (ms) → `native test` when Model/Msg or tests change → `native build` for ReleaseFast.
  </Step>
  <Step title="Agent / smoke automation">
    Build with automation, run app, then `native automate wait`, `snapshot`, `assert`, widget commands, or `record`/`replay` journals.
  </Step>
  <Step title="Environment diagnosis">
    `native doctor --manifest app.zon --strict` before packaging or CEF/WebView work.
  </Step>
</Steps>

## Failure quick map

| Symptom | First check |
| --- | --- |
| `no app.zon here` | Wrong cwd; pass `[dir]` or `native init` |
| Cannot locate framework | `NATIVE_SDK_PATH`, npm install completeness, or `--framework` on init |
| Zig pin / download declined | Install Zig 0.16.0 or re-run with `--yes` |
| Dev no hot reload | Ensure Debug (default); Release optimize disables reload |
| Automate silent no-op | App not running, wrong cwd, not built with automation, protocol skew |
| Check only structural | Run `native test` to emit model contract |
| Orphan app after killing CLI | Fixed for `dev` via process groups where supported; still kill by hand if unsupported |

## Next

<CardGroup>
  <Card title="Installation" href="/installation">
    Install @native-sdk/cli, platform packages, and binary success signals.
  </Card>
  <Card title="Quickstart" href="/quickstart">
    native init → native dev → first live window.
  </Card>
  <Card title="Agent skills" href="/agent-skills">
    Skill packs discoverable via native skills.
  </Card>
  <Card title="Testing" href="/testing">
    native test, headless truth drivers, and frame-level checks.
  </Card>
  <Card title="Automation" href="/automation">
    Dropbox protocol, snapshots, record/replay, screenshots.
  </Card>
  <Card title="Packaging" href="/packaging">
    Release binaries to distributable bundles.
  </Card>
  <Card title="Testing in CI" href="/testing-ci">
    Gate scripts and reproducible headless checks.
  </Card>
</CardGroup>

---

## 25. Runtime and effects

> Runtime API surface, effect kinds for spawn/fetch/file/audio/clipboard, cancellation, worker wakes, and deterministic frame limits.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/25-runtime-and-effects.md
- Generated: 2026-07-09T08:24:49.321Z

### Source Files

- `src/runtime/core.zig`
- `src/runtime/api.zig`
- `src/runtime/effects.zig`
- `src/runtime/flow.zig`
- `docs/src/app/runtime/layout.tsx`

---
title: "Runtime and effects"
description: "Runtime API surface, effect kinds for spawn/fetch/file/audio/clipboard, cancellation, worker wakes, and deterministic frame limits."
---

`Runtime` owns the platform event loop, windows, views, security policy, automation, tracing, and shell materialization. `Effects(Msg)` is the TEA command channel for `UiApp`: spawn, HTTP fetch, file I/O, clipboard, timers, audio, and window actions leave `update` only through that channel. Workers post fixed-size completion records, nudge the loop via `PlatformServices.wake_fn`, and the loop thread drains results as typed `Msg` values on `.effects_wake` (and again on each presented frame for host-pumped embeds).

## Architecture

```mermaid
flowchart TB
  subgraph appLayer [App layer]
    Model["Model"]
    Update["update(model, msg, fx)"]
    View["view → ShellConfig / canvas tree"]
  end

  subgraph runtimeLayer [Runtime]
    Run["Runtime.run(App)"]
    Flow["flow.zig dispatchPlatformEvent"]
    Event["Event union incl. effects_wake"]
    Drain["UiApp.drainEffects → takeMsg → update"]
  end

  subgraph effectsLayer [Effects channel]
    FX["Effects(Msg)"]
    Slots["max_effects = 16 slots"]
    Queue["MPSC completion queue"]
    Fake["executor .real | .fake"]
  end

  subgraph platformLayer [Platform]
    Loop["platform.run"]
    Wake["wake_fn / request_frame_fn"]
    Services["clipboard, timers, audio, windows"]
  end

  Update -->|"fx.spawn / fetch / …"| FX
  FX --> Slots
  Slots -->|"worker posts"| Queue
  Queue --> Wake
  Wake --> Loop
  Loop --> Flow
  Flow --> Event
  Event --> Drain
  Drain --> Update
  Update --> Model
  Model --> View
  Run --> Loop
  FX --> Services
  Fake -.->|"tests / session replay"| FX
```

Side effects never touch `Model` off-thread. Payload slices on effect messages point into drain scratch and are valid only for the `update` call that receives them — copy what the model keeps.

## Runtime API surface

Public re-exports live in `src/runtime/root.zig` and `src/runtime/api.zig`. The concrete loop and storage live in `src/runtime/core.zig`; platform event routing is in `src/runtime/flow.zig`.

### `App` and lifecycle

`App(Runtime)` is a type-erased host contract:

| Field | Type | Role |
| --- | --- | --- |
| `context` | `*anyopaque` | App state pointer |
| `name` | `[]const u8` | Traces and automation snapshots |
| `source` / `source_fn` | WebView source | Compatibility WebView startup |
| `scene_fn` | `?fn(*anyopaque) !ShellConfig` | Declarative native shell (UiApp path) |
| `start_fn` | optional | After runtime start, before scene/source load |
| `event_fn` | optional | Every `Event` (lifecycle, commands, effects_wake, canvas, …) |
| `stop_fn` | optional | Before shutdown; guaranteed once via flow teardown |
| `replay_fn` | optional | Session replay arm/feed for effect stubbing |

`LifecycleEvent`: `start`, `activate`, `deactivate`, `frame`, `stop`.

### `Event`

| Tag | Purpose |
| --- | --- |
| `lifecycle` | App lifecycle |
| `command` / `shortcut` / `timer` | Commands, keybindings, platform timers |
| `effects_wake` | Cross-thread worker nudge — drain effect queue on the loop thread |
| `audio` | Platform audio player reports (UiApp maps via `Effects.takeAudioMsg`) |
| `files_dropped` | Native file drops |
| `gpu_surface_*` | GPU surface frame, resize, input |
| `canvas_widget_*` | Pointer, keyboard, scroll, drag, dismiss, resize, change, context menu |
| `window_closed` / `automation_provenance` | Window teardown and automation provenance |

### `Runtime.Options`

| Field | Default | Notes |
| --- | --- | --- |
| `platform` | required | Host platform or null/headless test host |
| `trace_sink` | `null` | Structured traces |
| `security` | `{}` | Navigation allowlist, permissions |
| `bridge` / `builtin_bridge` | null / `{}` | WebView bridge dispatcher and policy |
| `automation` | `null` | File-based automation server |
| `commands` / `menus` / `shortcuts` | empty | Manifest-driven chrome |
| `window_state_store` | `null` | Geometry persistence |
| `js_window_api` | `false` | Trusted `window.zero` helpers |
| `environ` | `null` | Env inherited by spawn children; fallback for embed hosts |
| `session_recorder` | `null` | Record/replay journal; armed via `NATIVE_SDK_SESSION_RECORD` |
| `gpu_surface_frame_diagnostics` | `true` | GPU frame diagnostics |
| `pixel_present_retained_baseline` | `false` | Pixel-only hosts (mobile embed) |

### Core runtime methods

| Method | Role |
| --- | --- |
| `init(options)` / `run(app)` | Construct and enter the platform loop |
| `createWindow` / `listWindows` / `focusWindow` / `closeWindow` | Window control |
| `invalidate` / `invalidateFor` | Request redraw |
| `frameDiagnostics` | Last-frame stats |
| `setCanvasFrameBudget` | Per-view soft budget checks |
| `setGpuSurfaceInputLatencyBudget` | Input-to-frame latency budget |
| `dispatchEvent` / `dispatchPlatformEvent` | Inject or forward events |
| `automationSnapshot` | Write automation state |

`UiApp(Model, Msg)` wires `Effects(Msg)`, binds platform services on start, and implements `drainEffects` so most apps never call `Runtime` methods directly.

## Effects channel

`Effects(Msg)` in `src/runtime/effects.zig` is the only supported path for side effects from `update`:

```zig
pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
    switch (msg) {
        .start => fx.spawn(.{
            .key = stream_key,
            .argv = &stream_argv,
            .on_line = Effects.lineMsg(.line),
            .on_exit = Effects.exitMsg(.exited),
        }),
        .cancel => fx.cancel(stream_key),
        .copy_status => fx.writeClipboard(.{
            .key = clipboard_key,
            .text = text,
            .on_result = Effects.clipboardMsg(.copied),
        }),
        // ...
    }
}
```

Comptime Msg constructors: `lineMsg`, `exitMsg`, `responseMsg`, `fileMsg`, `clipboardMsg`, `timerMsg`, `audioMsg` — each builds a tagged union variant whose payload type matches the effect family.

### Shared capacity and key space

| Constant | Value | Applies to |
| --- | --- | --- |
| `max_effects` | 16 | In-flight slots shared by spawn, fetch, file, clipboard |
| `max_effect_queue_entries` | 64 | Combined completion queue depth |
| `max_effect_pending_exits` | 32 | Loop-thread pending terminal ring |
| `max_effect_timers` | 16 | Timers (own table; not effect slots) |
| `effect_error_exit_code` | `-1` | Non-`.exited` spawn exit code |

Caller-chosen `u64` keys identify effects. Spawn, fetch, file, and clipboard share one key space and the 16 slots — duplicate active keys reject. Timer keys and audio keys are separate namespaces (starting a timer key replaces it; `playAudio` replaces previous playback).

Overflow is never silent: rejections surface as terminal Msgs (`.rejected` / reason `.rejected`); dropped lines accumulate into `dropped_before` / `dropped_lines`.

### Effect kinds

#### Spawn

`fx.spawn(SpawnOptions)` runs a subprocess on a worker thread.

| Field | Default | Constraints |
| --- | --- | --- |
| `key` | required | Unique among active effects |
| `argv` | required | ≤ `max_effect_argv` (16) entries, ≤ `max_effect_argv_bytes` (2048) total |
| `stdin` | `null` | ≤ `max_effect_stdin_bytes` (4096); written once then closed |
| `output` | `.lines` | `.lines` streams stdout; `.collect` delivers whole stdout + stderr tail on exit |
| `max_line_bytes` | 4096 | Ceiling `max_effect_line_bytes_ceiling` (256 KiB); over-ceiling or zero → reject |
| `on_line` / `on_exit` | optional | Msg constructors |

`EffectExitReason`: `exited`, `signaled`, `cancelled`, `rejected`, `spawn_failed`.

Collect mode caps: `max_effect_collect_bytes` (512 KiB), `max_effect_stderr_tail_bytes` (4096). Lines mode ignores stderr.

Children inherit `Runtime.Options.environ` (or `fallbackEnviron()` on embed/mobile).

#### Fetch

`fx.fetch(FetchOptions)` — HTTP(S) only.

| Field | Default | Constraints |
| --- | --- | --- |
| `method` | `.GET` | |
| `url` | required | ≤ `max_effect_url_bytes` (2048); http(s) only |
| `headers` | empty | ≤ 8 headers, ≤ 1024 total header bytes |
| `body` | `null` | ≤ `max_effect_fetch_payload_bytes` (64 KiB) |
| `timeout_ms` | 30_000 | Whole exchange (stream lifetime for `.stream`) |
| `response` | `.buffered` | `.stream` frames body lines via `on_line` |
| `max_line_bytes` | 4096 | Stream mode only; same ceiling as spawn |
| `on_response` | optional | Exactly one terminal Msg |

`EffectFetchOutcome`: `ok`, `rejected`, `connect_failed`, `tls_failed`, `protocol_failed`, `timed_out`, `cancelled`. Body cap `max_effect_body_bytes` (256 KiB); oversize arrives truncated with `truncated = true`. HTTP non-2xx is still `ok` with the real status.

#### File

`fx.writeFile` / `fx.readFile` — worker-thread whole-file ops.

| Bound | Value |
| --- | --- |
| Path | `max_effect_file_path_bytes` (1024) |
| Payload | `max_effect_file_bytes` (1 MiB) |

Write over capacity is rejected outright (no partial write). Read oversize outcome is `.truncated` (not a flag on `.ok`). Outcomes: `ok`, `not_found`, `io_failed`, `truncated`, `rejected`, `cancelled`.

#### Clipboard

`fx.writeClipboard` / `fx.readClipboard` — loop-thread pasteboard calls (not workers). Cap `max_effect_clipboard_bytes` = `platform.max_clipboard_data_bytes` (65536). Outcomes: `ok`, `failed`, `rejected`, `cancelled`. Over-bound writes reject; cut strings never pass as whole content.

#### Timers

`fx.startTimer` / `fx.cancelTimer` — platform timer service, not effect slots.

| Field | Default |
| --- | --- |
| `interval_ms` | required; zero → `.rejected` |
| `mode` | `.one_shot` or `.repeating` |
| `on_fire` | optional → `EffectTimer` |

#### Audio

`fx.playAudio` / `pauseAudio` / `resumeAudio` / `stopAudio` / `seekAudio` / `setAudioVolume` — one player surface. Source cascade: local `path`, then verified `cache_path` for `url`, else progressive stream. Path/url bounds: `max_effect_audio_path_bytes` (1024). Events: `loaded`, `position`, `completed`, `failed`, `rejected`, `spectrum` (32 bands when host supports analysis). `audioCachePath` derives content-addressed cache paths under the OS cache dir.

#### Window actions

`fx.closeWindow(label)` / `fx.minimizeWindow(label)` — real OS verbs via `bindWindowActions` (UiApp wires this). Label capacity 64 bytes. Fake executor records requests without calling the host.

### Cancellation

```text
cancel(key)
  ├─ active slot → mark cancelled_generation, request cancel
  │     spawn: kill + reap → one on_exit { reason: .cancelled }
  │     fetch/file: interrupt worker → one terminal .cancelled
  │     clipboard: mark; real pasteboard may already have finished
  └─ finished-but-queued → drain rewrites terminal to .cancelled
       discards queued on_line for that generation
```

After `cancel(key)` returns: no further `on_line` for that spawn; exactly one terminal Msg. Timers use `cancelTimer(key)` separately. Audio stop uses `stopAudio()` (not `cancel`).

### Worker wakes and drain

```mermaid
sequenceDiagram
  participant Update as update on loop thread
  participant FX as Effects channel
  participant Worker as effect worker
  participant Plat as PlatformServices
  participant Flow as RuntimeFlow
  participant UiApp as UiApp.drainEffects

  Update->>FX: spawn / fetch / writeFile
  FX->>Worker: Thread.spawn slot
  Worker->>FX: post completion entry
  Worker->>Plat: wake_fn
  Plat->>Flow: platform .wake event
  Flow->>UiApp: Event.effects_wake
  UiApp->>FX: takeMsg loop
  UiApp->>Update: applyMsg for each result
  UiApp->>UiApp: rebuild view if any dispatched
```

1. `bindServices` points workers at the host wake service; `bindEnviron` / `bindImages` / `bindWindowActions` / `bindJournal` attach host context.
2. Workers call `wakeHost()` → `services.wake()`.
3. `flow.zig` maps platform `.wake` to `Event.effects_wake`.
4. `UiApp.drainEffects` runs `while (effects.takeMsg()) |msg| applyMsg(msg)`, then rebuilds once if anything dispatched.
5. Host-pumped embeds without wake delivery also drain on each presented frame.

Automation liveness uses a separate path: `request_frame_fn` so queued automation commands wake idle apps (one command per `frame_requested`).

### Fake executor and testing

`effects.executor = .fake` records requests and delivers only what tests feed:

| Feed API | Use |
| --- | --- |
| `feedLine` / `feedExit` / `feedExitReason` | Spawn |
| `feedResponseOutcome` | Fetch |
| `feedFileResult` / `feedClipboardResult` | File / clipboard |
| `fireTimer` | Timers |
| `feedAudioEvent` / `feedAudioSpectrum` | Audio |

Inspection: `pendingSpawnAt`, `pendingFetchAt`, `pendingFileAt`, `pendingClipboardAt`, `pendingTimerAt`, `pendingAudio`, `windowActionState`, `audioSnapshot`.

Session record/replay: `armReplay()` + journaled `EffectResultRecord` feeds; `NATIVE_SDK_SESSION_RECORD` arms the runtime recorder. Effect results drained during dispatch are journaled before the event so replay order stays correct.

## Deterministic frame limits

Two layers bound per-frame work:

### Hard per-view capacities (`canvas_limits.zig`)

Compile-time fixed arrays in `Runtime` / `RuntimeView`. Overflow errors name the budget; automation snapshots report headroom.

| Budget | Cap |
| --- | --- |
| `max_canvas_commands_per_view` | 2048 |
| `max_canvas_glyphs_per_view` | 8192 |
| `max_canvas_text_bytes_per_view` | 32768 |
| `max_canvas_path_elements_per_view` | 2048 |
| `max_canvas_widget_nodes_per_view` | 1024 |
| `max_canvas_widget_text_bytes_per_view` | 65536 |
| `max_canvas_text_layout_lines_per_view` | 8192 |
| `max_registered_canvas_images` | 16 |
| `max_ui_app_windows` | 4 |
| Retained GPU packet commands | = command budget |

These are absolute capacity, not soft targets.

### Soft `CanvasFrameBudget`

`Runtime.setCanvasFrameBudget(window_id, label, budget)` stores a `CanvasFrameBudget` of optional maxima (`max_commands`, `max_batches`, `max_glyph_atlas_entries`, `max_text_layouts`, …). Zero means unset for that field. Status compares frame diagnostics and reports `ok` / `exceededCount` for automation and view JSON (`canvasFrameBudgetOk`, `canvasFrameBudgetExceededCount`).

Use soft budgets in tests and CI to pin frame cost without changing hard engine capacities.

### Dispatch error ring

`max_dispatch_errors` (16) keeps degraded dispatch failures oldest-first; totals still accumulate beyond the ring for diagnostics.

## Operational constraints

| Constraint | Behavior |
| --- | --- |
| Payload lifetime | Copy effect slices inside the receiving `update` |
| Full completion queue | Lines drop with counters; exits still deliver |
| Slot exhaustion | Terminal `.rejected` Msg, not a panic |
| Teardown | `stop_fn` / `effects.deinit` join workers before platform host dies |
| Embed hosts | Drain on frame present when wake is unavailable |
| No Io in update | File and network only through effects |

## Verification signals

| Signal | Meaning |
| --- | --- |
| `examples/effects-probe` Start/Cancel/Copy | Spawn stream, cancel mid-stream, clipboard without `pbcopy` |
| `effects.executor = .fake` + feed APIs | Deterministic unit tests |
| Automation snapshot audio fields | Playback state mirrored from channel |
| View JSON budget fields | Soft budget exceeded |
| Session journal under `NATIVE_SDK_SESSION_RECORD` | Events + effect results + fingerprints |
| Trace `runtime.init` / `runtime.done` | Loop entered and exited cleanly |

## Related pages

<CardGroup>
  <Card title="App model" href="/app-model">
    Model, Msg, update wiring and UiApp over Runtime.
  </Card>
  <Card title="State and data flow" href="/state-data-flow">
    Derive-don't-store, message dispatch, effects as the only side-effect channel.
  </Card>
  <Card title="Capabilities" href="/capabilities">
    Guarded OS services and effect-channel access boundaries.
  </Card>
  <Card title="Testing" href="/testing">
    native test, headless drivers, effect tests, frame-level checks.
  </Card>
  <Card title="Automation" href="/automation">
    Snapshots, widget driving, record/replay, deterministic screenshots.
  </Card>
  <Card title="Bridge and builtin commands" href="/bridge">
    Host-to-content bridge payloads and builtin command policy.
  </Card>
</CardGroup>

---

## 26. Platform support and security

> Host capability matrix for macOS, Linux, Windows, and experimental mobile, plus bridge permission and capability security boundaries.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/26-platform-support-and-security.md
- Generated: 2026-07-09T08:23:47.674Z

### Source Files

- `docs/src/app/security/layout.tsx`
- `skill-data/core/references/bridge-security-native-capabilities.md`
- `src/platform/macos/appkit_host.m`
- `src/platform/linux/gtk_host.h`
- `src/tooling/android.zig`
- `src/tooling/ios.zig`

---
title: "Platform support and security"
description: "Host capability matrix for macOS, Linux, Windows, and experimental mobile, plus bridge permission and capability security boundaries."
---

Native SDK runs desktop apps through platform hosts in `src/platform/` (AppKit on macOS, GTK on Linux, Win32/WebView2 on Windows) and treats iOS/Android as experimental host tiers built on the embed C ABI. Feature gaps return `error.UnsupportedService` or bridge `permission_denied` rather than silent no-ops. Apps declare `permissions` and `capabilities` in `app.zon`; WebView content is untrusted until bridge policy, origin allowlists, and navigation rules opt it into native power.

## Support matrix

macOS, Linux, and Windows host full desktop apps. Mobile is experimental: simulator/emulator paths are verified, but APIs and tooling may still change. iOS and Android canvas apps are toolkit-owned hosts generated from `app.zon` via `native dev --target ios|android` and `native package --target ios|android` — the app project carries zero host code. Multi-window scenes remain desktop-only.

| Surface | macOS | Windows | Linux | iOS (experimental) | Android (experimental) |
| --- | --- | --- | --- | --- | --- |
| Real OS windows | Full | Full | Full | Full-screen single window in toolkit UIKit host | Full-screen single window in toolkit Android host |
| Native rendering | Metal + OS scroll physics | Software renderer, GDI blit | Software renderer, cairo blit | Software renderer; host presents via Metal | Software renderer; host copies pixels into surface |
| Pointer, keyboard, IME | Full | IME mapped; hardware IME verification pending | Full (pointer, keyboard, scroll, IME, HiDPI) | Touch, system keyboard, IME via host | Touch, soft keyboard, IME via host |
| Menus | Native app + context menus | Native app menus; context as anchored canvas | Native app menus; context as anchored canvas | None | None |
| System tray | Full (`NSStatusItem`) | Full | `UnsupportedService` until portable status-notifier | None | None |
| Web content | System WebView default; optional CEF Chromium | System WebView (maturing); no engine for pure canvas apps | System WebView | System WebView only in embed/host shell workspace | System WebView only in embed/host shell workspace |
| Packaging | `.app`, DMG, generated icons | Directory artifact + file-type registration; installer later | Install tree + desktop entry/MIME; AppImage later | Generated Xcode project; archive-ready; signing manual | Generated host + debug APK; store signing manual |
| Code signing | Ad-hoc and identity + entitlements; notarization manual | None | None | None | None |
| Automation | Full | Full (desktop + CI emulation) | Full under Xvfb | File-based protocol inside app container (simulator) | File-based protocol in app files dir (emulator) |

Unsupported host operations reject explicitly. Use `Runtime.supports(...)` / `native-sdk.platform.supports` before branching UI.

## Desktop hosts

| Area | macOS system WebView | macOS Chromium | Linux system WebView | Linux Chromium | Windows system WebView |
| --- | --- | --- | --- | --- | --- |
| Main / child WebViews | Supported | Supported | Supported | Unsupported | Supported |
| Native views / control commands | Supported | Unsupported | Supported | Unsupported | Supported |
| App menus | Supported | Unsupported | Supported | Unsupported | Supported |
| Context menus | Native | Native | Anchored canvas fallback | Anchored canvas fallback | Anchored canvas fallback |
| Tray | Supported | Supported | Unsupported | Unsupported | Supported |
| Shortcuts, dialogs, clipboard, open URL, reveal path, notifications, recent docs | Supported | Supported (subset per host) | Supported | Unsupported | Supported |
| Credentials | Supported | Supported | When secret service present | Unsupported | Supported |
| File drops | Supported | Unsupported | Supported | Unsupported | Supported |
| Audio playback / streaming / spectrum | Supported | Unsupported | When GStreamer present | Unsupported | Supported (spectrum needs Win10 2004+) |

Chromium (`web_engine = "chromium"`) is implemented on macOS only. Linux/Windows Chromium host paths are placeholders; tooling rejects those targets instead of substituting an engine.

**Presentation path:** macOS uses Metal. Linux and Windows present the deterministic software renderer (`backend=software`); a manifest requesting another GPU backend falls back to software on those hosts.

**Window chrome:** `titlebar = "hidden_inset"` / `"hidden_inset_tall"`, `window-drag` regions, and `on_chrome` insets work on AppKit, GTK (CSD header bar), and Win32 (caption reclaim + DWM buttons). Mobile hosts answer `on_chrome` with safe-area insets.

## Experimental mobile

### Toolkit-owned hosts

| Target | Dev loop | Package output | Host identity |
| --- | --- | --- | --- |
| iOS | `native dev --target ios` → embed lib for simulator, UIKit host, `simctl` launch | `native package --target ios` → complete Xcode project + device-slice library | Deployment target `15.0`; bundle id maps `_` → `-` |
| Android | `native dev --target android` → embed lib `aarch64-linux-android`, assemble/install debug APK via `adb` | `native package --target android` → generated host project + debug APK | minSdk `30`, targetSdk `35`, ABI `arm64-v8a`; application id maps hyphens → underscores |

Both hosts drive the app over the embed C ABI (`src/embed/`), register platform audio and image decode services, and forward touch/keyboard/IME. Frames use the deterministic CPU reference renderer (mobile GPU rendering is a later phase). Markup hot reload does not reach simulator/device yet — edit, then re-run `native dev --target …`.

### Mobile transport security (host-generated)

| Platform | Policy |
| --- | --- |
| iOS | `NSAllowsLocalNetworking` only — localhost/local network cleartext for the dev media loop; HTTPS required on the public internet |
| Android | `network_security_config.xml`: cleartext denied by default; cleartext allowed only for `localhost`, `127.0.0.1`, and emulator host `10.0.2.2`. Manifest declares `INTERNET`; APK stays `debuggable` for automation `run-as` |

### Embed vs canvas shell

Embedding on iOS/Android (UIKit/`WKWebView` or Android views/`WebView` workspace) shares experimental status with the toolkit host tier. Dynamic generic native-view bridge mutation remains a desktop API surface until mobile runtime view hosts land. See [Embedded app](/embed).

## Runtime support queries

`PlatformFeature` names the host capability surface (`main_webview`, `child_webviews`, `native_views`, `menus`, `tray`, `shortcuts`, `dialogs`, `clipboard_text`, `clipboard_rich_data`, `open_url`, `reveal_path`, `notifications`, `recent_documents`, `credentials`, `file_drops`, `app_activation_events`, `gpu_surfaces`, `gpu_surface_scroll_drivers`, `context_menus`, `view_surface_adoption`, `audio_playback`, `audio_streaming`, `audio_spectrum`).

```zig
if (runtime.supports(.native_views)) {
    try runtime.createView(.{ .label = "sidebar", .kind = .sidebar });
}
```

```javascript
const hasTray = await window.zero.platform.supports("tray");
// snake_case or camelCase: native_views / nativeViews
```

`native-sdk.platform.supports` requires `js_window_api` or an explicit `builtin_bridge` entry plus origin/permission checks. Missing features still reject with `error.UnsupportedService` if called blindly.

```mermaid
flowchart TB
  subgraph app [App process]
    ZON["app.zon permissions capabilities bridge security"]
    RT["Runtime + bridge policy"]
    CANVAS["Native-rendered UI / effects"]
    WV["WebView content window.zero"]
  end
  subgraph hosts [Platform hosts]
    MAC["macos/appkit_host"]
    LIN["linux/gtk_host"]
    WIN["windows webview2_host"]
    MOB["ios uikit_host / android host experimental"]
  end
  ZON --> RT
  RT --> CANVAS
  RT --> WV
  RT --> MAC
  RT --> LIN
  RT --> WIN
  RT --> MOB
  WV -->|"invoke + origin"| RT
  RT -->|"set_security_policy allowed_origins external_links"| MAC
  RT -->|"set_security_policy"| LIN
  RT -->|"set_security_policy"| WIN
```

## Security model

### Permissions vs capabilities

Declare both in `app.zon`:

```zig
.permissions = .{ "command", "view", "dialog", "window", "filesystem" },
.capabilities = .{ "webview", "js_bridge", "native_views", "dialog", "filesystem" },
```

| Kind | Role |
| --- | --- |
| `capabilities` | Broad features the app uses (manifest/feature inventory: webview, bridge, menus, tray, packaging metadata, …) |
| `permissions` | Runtime grants checked before native bridge commands and OS services run |

**Known permissions** (manifest parser; unknown strings become custom grants): `window`, `command`, `view`, `dialog`, `filesystem`, `clipboard`, `credentials`, `network`, `notifications`, `camera`, `microphone`, `location`. Custom permissions use reverse-DNS names (for example `com.example.my-permission`). Prefer the smallest set that covers the app.

**Known capabilities** (invalid names fail validation): `native_module`, `webview`, `js_bridge`, `native_views`, `gpu_surfaces`, `menus`, `shortcuts`, `tray`, `filesystem`, `network`, `notifications`, `dialog`, `clipboard`, `credentials`, `open_url`, `reveal_path`, `recent_documents`, `file_drops`, `app_activation_events`, `file_associations`, `url_schemes`.

Runtime security constants in `src/security/root.zig` include `permission_window`, `permission_command`, `permission_view`, `permission_dialog`, `permission_filesystem`, `permission_clipboard`, `permission_network`, `permission_notifications`, and `permission_credentials`.

### Default security policy

| Field | Default |
| --- | --- |
| `permissions` | Empty — nothing granted |
| `navigation.allowed_origins` | `zero://app`, `zero://inline` |
| `navigation.external_links.action` | `deny` |
| `navigation.external_links.allowed_urls` | Empty |

Hosts receive navigation policy through platform entry points such as `native_sdk_appkit_set_security_policy` (and GTK/Windows peers): allowed origins default to `zero://app` / `zero://inline` when unset; external URL lists and action flags gate system-browser opens.

### Bridge commands (default-deny)

JavaScript reaches Zig through:

```javascript
const result = await window.zero.invoke("native.ping", { source: "webview" });
```

Dispatch path:

1. Parse JSON request  
2. Enforce message size limits  
3. Check origin + required permissions against policy  
4. Look up registered handler  
5. Run handler and return JSON response  

A command must be **registered in Zig** and **allowed by policy**. Policy shape:

```zig
.bridge = .{
    .commands = .{
        .{ .name = "native.ping", .origins = .{ "zero://app" } },
        .{
            .name = "native-sdk.window.create",
            .permissions = .{ "window" },
            .origins = .{ "zero://app" },
        },
    },
},
```

Prefer exact origins over `"*"`. Wildcard is only appropriate for local development or commands that expose no native state.

**Bridge size limits** (`src/bridge/root.zig`):

| Limit | Value |
| --- | --- |
| Request / response / handler result | 1 MiB each (`max_message_bytes`, `max_response_bytes`, `max_result_bytes`) |
| Request id | 64 bytes |
| Command name | 128 bytes |

### Builtin bridge and `js_window_api`

Builtins (`native-sdk.command.*`, `native-sdk.window.*`, `native-sdk.view.*`, `native-sdk.webview.*`, `native-sdk.platform.*`, `native-sdk.dialog.*`, `native-sdk.os.*`, `native-sdk.clipboard.*`, `native-sdk.credentials.*`) are gated separately via `RuntimeOptions.builtin_bridge`.

When `builtin_bridge.enabled` is false:

- Commands with a `js_permission` require `js_window_api = true`, an allowed origin, and the matching permission (legacy: a bare `window` grant still satisfies some command/view helpers).
- Dialog, OS capability, clipboard, and credential builtins stay **default-deny** until listed in an explicit `builtin_bridge` policy.

```zig
.builtin_bridge = .{
    .enabled = true,
    .commands = &.{
        .{ .name = "native-sdk.command.invoke", .permissions = .{ "command" }, .origins = .{ "zero://app" } },
        .{ .name = "native-sdk.platform.supports", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
        .{ .name = "native-sdk.window.create", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
        .{ .name = "native-sdk.dialog.openFile", .permissions = .{ "dialog" }, .origins = .{ "zero://app" } },
        .{ .name = "native-sdk.os.openUrl", .permissions = .{ "network" }, .origins = .{ "zero://app" } },
        .{ .name = "native-sdk.clipboard.readText", .permissions = .{ "clipboard" }, .origins = .{ "zero://app" } },
        .{ .name = "native-sdk.credentials.get", .permissions = .{ "credentials" }, .origins = .{ "zero://app" } },
    },
},
```

Additional rules:

- View and WebView commands may only target the **calling** window.
- WebView navigations must pass `security.navigation.allowed_origins`.
- Child WebViews get `window.zero` only when created with `bridge: true`.
- Label `main` is reserved for the startup WebView.
- `native-sdk.os.openUrl` also requires `external_links` allowlisting; a bridge grant alone is not enough.

### Navigation and external links

```zig
.security = .{
    .navigation = .{
        .allowed_origins = .{
            "zero://app",
            "zero://inline",
            "http://127.0.0.1:5173",
        },
        .external_links = .{
            .action = "open_system_browser",
            .allowed_urls = .{ "https://example.com/docs/*" },
        },
    },
},
```

| Rule | Behavior |
| --- | --- |
| Origin allowlist | Exact match or `"*"`; unknown main-frame navigations blocked |
| External links | Denied unless `action = open_system_browser` and URL matches exact entry or `https://…/*` / `http://…/*` prefix (scheme + host + path prefix required; bare host wildcards rejected) |
| Same gate | `runtime.openExternalUrl(...)` and `window.zero.os.openUrl(...)` |

### Bridge error codes

JavaScript rejections expose `error.code`:

| Code | Meaning |
| --- | --- |
| `invalid_request` | Malformed input, denied navigation URL, missing target, reserved/duplicate label |
| `unknown_command` | No registered handler |
| `permission_denied` | Origin or permission check failed |
| `handler_failed` | Handler returned an error |
| `payload_too_large` | Request exceeds size limit |
| `internal_error` | Unexpected runtime failure |

### CSP

The runtime does **not** enforce Content Security Policy. Set CSP in app HTML. Packaged assets should start strict (`default-src 'self'`); dev servers may extend `connect-src` only for the local origin/WebSocket. Keep production CSP separate from development CSP.

### Layer summary

| Layer | Default | Opt-in |
| --- | --- | --- |
| App bridge commands | Denied | Per-command policy with origin + permissions |
| Builtin helpers (command/window/view/webview/platform) | Denied unless `js_window_api` or explicit policy | Matching permission + allowed origins |
| Builtin dialogs / OS / clipboard / credentials | Denied | Explicit `builtin_bridge` command list |
| Navigation | Blocked | `allowed_origins` |
| External links | Denied | `open_system_browser` + URL allowlist |
| Permissions | None | Declared in `app.zon`, checked at runtime |
| Host feature | Present or `UnsupportedService` | Query via `supports` / `platform.supports` |
| CSP | Not enforced by SDK | HTML `<meta>` |

Defense in depth: a Zig-registered command still fails unless policy allows the origin and required permissions.

## Verification signals

| Check | Expected signal |
| --- | --- |
| Desktop host live | Showcase apps under real windows (`tools/linux-truth` under Xvfb; `tools/windows-truth` on Windows desktop); CI headless canvas smokes |
| iOS host | `native dev --target ios` on simulator; package output archives with `xcodebuild` |
| Android host | `native dev --target android` on emulator; package assembles debug APK; embed ABI cross-compile in CI |
| Bridge deny | `permission_denied` / `unknown_command` from `window.zero.invoke` |
| Feature gap | `error.UnsupportedService` or `supports(...) == false` |
| External URL | Denied unless action + allowlist match (including prefix rules that reject host-boundary tricks) |

## Related pages

<CardGroup>
  <Card title="Capabilities" href="/capabilities">
    Guarded OS services: notifications, clipboard, credentials, dialogs, file drops, and effect-channel boundaries.
  </Card>
  <Card title="Bridge and builtin commands" href="/bridge">
    Host-to-content bridge payloads, async dispatch, builtin command catalog, and permission wiring.
  </Card>
  <Card title="Web engines" href="/web-engines">
    System WebView vs CEF, engine flags, and when canvas UI coexists with web content.
  </Card>
  <Card title="Windows and surfaces" href="/windows-surfaces">
    Multi-window composition, OS chrome, GPU surfaces, and host presentation.
  </Card>
  <Card title="Embedded app" href="/embed">
    C embed ABI, mobile host shims, and experimental iOS/Android integration.
  </Card>
  <Card title="Code signing" href="/code-signing">
    macOS codesign, entitlements, and package pipeline signing constraints.
  </Card>
  <Card title="Packaging" href="/packaging">
    Release binaries, platform package layouts, and mobile generated projects.
  </Card>
  <Card title="Installation" href="/installation">
    Platform package layout, prerequisites, and success signals per host OS.
  </Card>
</CardGroup>

---

## 27. Contributing

> Local development setup, agent guidance, release and changelog merge process, and pre-1.0 contribution expectations.

- Page Markdown: https://grok-wiki.com/public/docs/vercel-labs-native-8ccc3580636a/pages/27-contributing.md
- Generated: 2026-07-09T08:23:03.443Z

### Source Files

- `CONTRIBUTING.md`
- `AGENTS.md`
- `RELEASING.md`
- `scripts/changelog-merge.sh`
- `CHANGELOG.md`

---
title: "Contributing"
description: "Local development setup, agent guidance, release and changelog merge process, and pre-1.0 contribution expectations."
---

The toolkit repository is developed as a Zig engine plus an npm-distributed CLI (`@native-sdk/cli`). Contributors work against `main` with a path-aware local gate (`scripts/gate.sh`), ship user-visible notes as `changelog.d/` fragments rather than editing `CHANGELOG.md`, and leave releases to maintainers who bump `packages/native-sdk/package.json` and let `.github/workflows/release.yml` publish.

<Info>
Native SDK is **pre-1.0**: public APIs still move. Bug reports and focused pull requests are welcome; larger design changes should start as an issue before code.
</Info>

## Prerequisites

<table>
  <thead>
    <tr>
      <th>Requirement</th>
      <th>Use</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><a href="https://ziglang.org/download/">Zig 0.16.0+</a></td>
      <td>Engine, runtime, examples, and most local checks</td>
    </tr>
    <tr>
      <td>Node.js + npm</td>
      <td>CLI package under <code>packages/native-sdk</code>, generated frontend projects</td>
    </tr>
    <tr>
      <td>pnpm</td>
      <td>Documentation site under <code>docs/</code></td>
    </tr>
    <tr>
      <td>macOS</td>
      <td>Primary host; WKWebView and Chromium/CEF development</td>
    </tr>
    <tr>
      <td>Linux + GTK4 + WebKitGTK 6</td>
      <td>System WebView path on Linux</td>
    </tr>
  </tbody>
</table>

App-author docs live at [zero-native.dev](https://zero-native.dev). This page is for changes to the toolkit repository itself.

## Repository layout

:::files
.
├── src/                 # Engine and runtime (canvas/markup under src/primitives/canvas/)
├── examples/            # Showcase apps (most: app.zon + src/ only)
├── packages/native-sdk/ # npm CLI (@native-sdk/cli) and platform packages
├── docs/                # Next.js docs site (docs/AGENTS.md for MDX conventions)
├── skills/ skill-data/  # Agent skills shipped with the CLI
├── tools/ scripts/      # Dev tooling, gate, changelog merge
├── changelog.d/         # Per-change fragments (not CHANGELOG.md)
├── CONTRIBUTING.md
├── AGENTS.md
└── RELEASING.md
:::

## Local development checks

### Core engine

```bash
zig build test                 # root engine + runtime suites
zig build validate             # sample app.zon manifest check
zig build test-example-notes   # one example suite (pattern: test-example-<name>)
```

### Web engine paths

Default development loop uses the system WebView:

```bash
zig build run-webview -Dweb-engine=system
zig build test-webview-system-link
```

Chromium on macOS (install CEF first):

```bash
native cef install
zig build run-webview -Dweb-engine=chromium
zig build test-webview-cef-smoke -Dplatform=macos -Dweb-engine=chromium
zig build test-package-cef-layout -Dplatform=macos
```

### CLI package and docs

```bash
npm --prefix packages/native-sdk run version:check
npm --prefix packages/native-sdk run scripts:check

pnpm --dir docs install --frozen-lockfile
pnpm --dir docs check
```

### Packaging and signing

```bash
zig build package
native package --target macos --manifest app.zon --assets assets --binary zig-out/lib/libnative-sdk.a
zig build test-package-signing   # ad-hoc codesign survival (macOS; skips loudly without codesign)
```

For Chromium packages, set `.web_engine = "chromium"` and `.cef` in `app.zon`, or pass temporary `--web-engine` / `--cef-dir` overrides.

### Automation development

```bash
zig build run-webview -Dautomation=true
native automate wait
native automate list
native automate bridge '{"id":"ping","command":"native.ping","payload":null}'
```

Automation artifacts land under `.zig-cache/native-sdk-automation`.

## Local gate

`scripts/gate.sh` is the required pre-PR check. It maps your diff (including untracked files) against a base ref (default `main`) to the suites that cover it, runs every step even after a failure, and exits non-zero if any step failed.

```bash
scripts/gate.sh fast           # affected-only: root suites + touched examples
scripts/gate.sh full           # CI-shaped local suite set
scripts/gate.sh full --all     # force docs check even if docs/ unchanged
scripts/gate.sh full --perf    # add macOS GPU percentile perf check (slow, load-sensitive)
```

### Path → suite mapping (`fast`)

<table>
  <thead>
    <tr>
      <th>Changed paths</th>
      <th>What runs</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code>src/**</code>, <code>build.zig</code>, <code>build.zig.zon</code>, <code>build/**</code>, <code>tools/**</code>, <code>tests/**</code>, <code>assets/**</code></td>
      <td>Framework change: root suites + <strong>all</strong> example suites</td>
    </tr>
    <tr>
      <td><code>src/platform/macos/**</code></td>
      <td>Above, plus Chromium host link (<code>cef-host-link</code>) on macOS when CEF is present</td>
    </tr>
    <tr>
      <td><code>examples/&lt;name&gt;/**</code></td>
      <td>That example only (<code>test-example-&lt;name&gt;</code>; mobile group → <code>test-examples-mobile</code>)</td>
    </tr>
    <tr>
      <td><code>docs/**</code></td>
      <td>Docs-only diffs: only <code>pnpm --dir docs check</code></td>
    </tr>
    <tr>
      <td>Other (README, <code>.github</code>, packages, scripts, skills, <code>changelog.d</code>, …)</td>
      <td>Root suites only</td>
    </tr>
  </tbody>
</table>

`full` always runs root test + validate, every example suite, Chromium host link, macOS automation smokes (when on macOS), markup check over example markup, and the docs check when `docs/` changed or `--all` is set.

### Render-benchmark ratchet

Framework diffs on macOS also run `zig build bench-render -Doptimize=ReleaseFast -- --check tools/bench-render-budgets.txt` by default. Skip only when the machine is too noisy:

```bash
NATIVE_SDK_SKIP_BENCH_CHECK=1 scripts/gate.sh fast
```

## Agent and maintainer conventions

`AGENTS.md` is the short operating contract for humans and coding agents:

- Run `scripts/gate.sh fast` before finishing a change.
- Do **not** edit `CHANGELOG.md` for day-to-day work; use `changelog.d/` fragments for user-visible changes. Internal-only polish needs no fragment.
- Update pinned goldens (pixel signatures, schema fingerprints, command counts) deliberately: review the rendered or counted output first; keep the pin’s comment a self-contained description of the value.
- Docs MDX tables use HTML `<table>` syntax (`docs/AGENTS.md`), not markdown pipe tables.

Skills the CLI ships live under `skills/` and `skill-data/` (`native skills list`).

## Changelog fragments

Concurrent branches do not edit `CHANGELOG.md`. Each user-visible change adds `changelog.d/<slug>.md`.

### Fragment format

<table>
  <thead>
    <tr>
      <th>Rule</th>
      <th>Detail</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>First line</td>
      <td>Tag + text: <code>feature:</code>, <code>improvement:</code>, or <code>fix:</code> followed by the bullet body</td>
    </tr>
    <tr>
      <td>Further lines</td>
      <td>Extra bullets (prefer <code>- </code> prefix; bare lines get <code>- </code> prefixed on merge)</td>
    </tr>
    <tr>
      <td>One tag per file</td>
      <td>Multi-section changes ship multiple fragments</td>
    </tr>
    <tr>
      <td>Voice</td>
      <td>Bold lead-in, then the story; one line per bullet (never hard-wrap)</td>
    </tr>
  </tbody>
</table>

Tag → section mapping:

| Tag | `CHANGELOG.md` section |
| --- | --- |
| `feature:` | `### New Features` |
| `improvement:` | `### Improvements` |
| `fix:` | `### Bug Fixes` |

Example fragment:

```markdown
improvement: **Faster frobnication**: the frobnicator now memoizes per-frame, cutting rebuild time ~40% on the kanban example.
- **Frobnication telemetry**: automation snapshots report `frob_cache_hits=`.
```

### Merge script

```bash
scripts/changelog-merge.sh
```

Behavior:

1. Reads every `changelog.d/*.md` except `changelog.d/README.md`.
2. Appends bullets to the end of the matching section under `## Unreleased` (creates missing sections in order: New Features, Improvements, Bug Fixes; creates `## Unreleased` if absent).
3. Deletes merged fragments.
4. **Fails loudly** on unknown tags or malformed first lines (silent drops are not allowed).

Merge is typically run during release prep, not on every PR.

## Pull requests

<Steps>
  <Step title="Branch from main">
    Fork if you lack push access. Keep the change focused.
  </Step>
  <Step title="Implement and gate">
    Run `scripts/gate.sh fast` (or `full` for broad/framework work). Fix failures before opening the PR.
  </Step>
  <Step title="Changelog fragment">
    If the change is user-visible, add `changelog.d/<slug>.md`. Do not edit `CHANGELOG.md`.
  </Step>
  <Step title="Open the PR">
    Target `main`. Describe what changed and why. For larger design shifts, open an issue first.
  </Step>
  <Step title="Signed commits">
    Commits must be cryptographically signed (`git commit -S` or `commit.gpgsign = true`) so GitHub shows **Verified**. The `Signed-off-by` trailer from `git commit -s` is a DCO attestation, not a signature.
  </Step>
</Steps>

CI (`.github/workflows/ci.yml`) exercises Zig core, platform WebView link/smoke paths, package signing on macOS, GPU smokes/perf jobs, and related matrix work. Local `gate.sh full` is the closest self-service mirror.

## Pre-1.0 contribution expectations

- Prefer **small, focused PRs** with clear verification (gate suites, example tests, or a concrete repro for bugs).
- Treat mobile (iOS/Android) surfaces as experimental; desktop hosts are the mature contribution surface.
- Do not treat public APIs as frozen; match existing patterns in `src/`, examples, and skills rather than inventing parallel APIs.
- Maintainers control release voice and timing; contributors ship fragments, not release branches, unless asked.

## Releases (maintainers)

Releases are **manual, single-PR** events. The maintainer owns changelog voice and format.

### Prepare a release PR

1. Branch (for example `prepare-v1.2.0`).
2. Bump the version in `packages/native-sdk/package.json` (source of truth).
3. Run `npm --prefix packages/native-sdk run version:sync` — stamps the version into `tools/native-sdk/main.zig`, every package under `packages/native-sdk/npm/*/package.json`, and `@native-sdk/cli` `optionalDependencies` pins.
4. Run `scripts/changelog-merge.sh` to fold pending `changelog.d/` fragments into `## Unreleased`.
5. Write the release entry in `CHANGELOG.md`, wrapped in `<!-- release:start -->` … `<!-- release:end -->`.
6. Remove those markers from the **previous** release entry; only the latest release keeps them.
7. Open a PR and merge to `main`.

### What CI does after merge

On push to `main`, `.github/workflows/release.yml`:

1. Compares `packages/native-sdk/package.json` version to npm `@native-sdk/cli`.
2. If the version is **new**: cross-builds CLI binaries for all platforms, creates the GitHub release (`v$VERSION`) using notes between the release markers, publishes the eight `@native-sdk/cli-*` platform packages under `packages/native-sdk/npm/*`, then publishes `@native-sdk/cli` last so the main package only lands after every binary it pins is live.
3. If npm already has the version but the GitHub release is missing assets: recreates/updates the GitHub release from the marked changelog entry.

Publishing uses **npm trusted publishing (OIDC)** — no npm token secret. Each of the nine packages must have a trusted publisher on npmjs.com for repository `vercel-labs/zero-native`, workflow `release.yml`, environment `Release`. Publishes run with `--provenance`. Missing trusted-publisher configuration fails with an OIDC authentication error for that package.

Verify version pins before release:

```bash
npm --prefix packages/native-sdk run version:check
```

## Related pages

<CardGroup>
  <Card title="Testing" href="/testing">
    Full-loop UI tests, headless truth drivers, and frame-level verification.
  </Card>
  <Card title="Testing in CI" href="/testing-ci">
    CI workflows, gate scripts, platform truth drivers, and eval harness wiring.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    native init, dev, check, test, build, automate, doctor, and skills commands.
  </Card>
  <Card title="Agent skills" href="/agent-skills">
    Shipped skill packs and how agents discover them via the CLI.
  </Card>
  <Card title="Packaging" href="/packaging">
    ReleaseFast binaries to distributable app bundles and asset layout.
  </Card>
  <Card title="Updates and distribution" href="/updates-distribution">
    npm multi-platform package layout, version sync, and binary distribution.
  </Card>
  <Card title="Code signing" href="/code-signing">
    Codesign tooling and signing constraints for package pipelines.
  </Card>
  <Card title="Overview" href="/overview">
    What the SDK exposes and the path from install to a running window.
  </Card>
</CardGroup>

---
