chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:49 +08:00
commit 505bac6b53
1470 changed files with 457757 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import { getSearchIndex } from "@/lib/search-index";
export async function GET(req: NextRequest) {
const q = req.nextUrl.searchParams.get("q")?.trim().toLowerCase();
if (!q) {
return NextResponse.json({ results: [] });
}
const index = await getSearchIndex();
const terms = q.split(/\s+/).filter(Boolean);
const results = index
.map((entry) => {
const titleLower = entry.title.toLowerCase();
const contentLower = entry.content.toLowerCase();
const titleMatch = terms.every((t) => titleLower.includes(t));
const contentMatch = terms.every((t) => contentLower.includes(t));
if (!titleMatch && !contentMatch) return null;
let snippet = "";
if (contentMatch) {
const firstTermIdx = Math.min(
...terms.map((t) => {
const idx = contentLower.indexOf(t);
return idx === -1 ? Infinity : idx;
})
);
if (firstTermIdx !== Infinity) {
const start = Math.max(0, firstTermIdx - 40);
const end = Math.min(entry.content.length, firstTermIdx + 120);
snippet =
(start > 0 ? "..." : "") +
entry.content.slice(start, end).replace(/\n/g, " ") +
(end < entry.content.length ? "..." : "");
}
}
return {
title: entry.title,
href: entry.href,
snippet,
score: titleMatch ? 2 : 1,
};
})
.filter(
(
r
): r is {
title: string;
href: string;
snippet: string;
score: number;
} => r !== null
)
.sort((a, b) => b.score - a.score)
.slice(0, 20)
.map(({ score: _, ...rest }) => rest);
return NextResponse.json({ results }, { headers: { "Cache-Control": "public, max-age=60" } });
}
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("app-model");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+163
View File
@@ -0,0 +1,163 @@
import { CodeToggle } from "@/components/code-toggle";
# App Model
A Native SDK app is one loop with four parts:
- **Model** — a plain data structure holding all app state.
- **Msg** — a tagged union of everything that can happen.
- **update(model, msg)** — the only place state changes.
- **View** — a Native markup file (`.native`, or a Zig view function) that derives the UI from the model.
The runtime owns everything else: window creation, GPU presentation, resize, pointer and keyboard dispatch, timers, accessibility, and hot reload. Your code never handles a raw event — input lands on a widget, the widget's bound message dispatches into `update`, the view rebuilds from the new model, and the engine repaints what changed.
The loop is the same in both authoring languages. By default the core is TypeScript (`src/core.ts`, compiled to native code at build time — [TypeScript Cores](/typescript) covers that tier in depth); a Zig core (`src/main.zig`, from `native init --template zig-core`) is first-class by choice, and the rest of this page — wiring, identity, hot reload — applies to both. The Zig-specific wiring sections below are exactly what the build generates for a TypeScript app, so they double as its eject story.
## The loop in full
A minimal counter is the complete shape — state, transitions, one pure `update`:
<CodeToggle>
```ts:src/core.ts
export interface Model {
readonly count: number;
}
export type Msg =
| { readonly kind: "increment" }
| { readonly kind: "decrement" }
| { readonly kind: "reset" };
export function initialModel(): Model {
return { count: 0 };
}
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) {
case "increment":
return { ...model, count: model.count + 1 };
case "decrement":
return { ...model, count: model.count - 1 };
case "reset":
return { ...model, count: 0 };
}
}
```
```zig:src/main.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,
}
}
```
</CodeToggle>
The view in `src/app.native` binds the model and names the messages:
```html
<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>
```
Markup can never mutate state. `{count}` is a read; `on-press="increment"` names a `Msg` variant (in a TypeScript core, the arm's `kind`). Every state change flows through `update`, which makes the app's behavior testable as a plain function — the Zig template's generated `src/tests.zig` drives it with no GUI at all, and a TypeScript core runs under node the same way (`native dev --core`).
## Wiring
`native_sdk.UiApp(Model, Msg)` ties the loop to the runtime. A zero-config app never writes this — the build graph generates it (for a TypeScript core, over the transpiled model) — but it is ordinary code you can own any time. From the Zig template's `main`:
```zig
const CounterApp = native_sdk.UiApp(Model, Msg);
pub fn main(init: std.process.Init) !void {
// `create` heap-allocates the multi-MB app struct and constructs the
// Model in place — neither ever rides the stack.
const app_state = try CounterApp.create(std.heap.page_allocator, .{
.name = "my_app",
.scene = shell_scene, // one window, one gpu_surface view
.canvas_label = "main-canvas", // must match the scene's view label
.update_fx = update, // update(model, msg, fx) - or .update for a pure app
.markup = .{ .source = app_markup, .watch_path = "src/app.native", .io = init.io },
});
defer app_state.destroy();
app_state.model = initialModel(); // boot state: assign through the pointer
try runner.runWithOptions(app_state.app(), .{ ... }, init);
}
```
`create` requires every `Model` field to carry a default; the model starts as `.{}` and boot state is assigned through the returned pointer. The `scene` declares the native window and its GPU surface view — see [Windows](/windows) and [Native Surfaces](/native-surfaces) for multi-view scenes.
## Rebuilds and widget identity
After every `update`, the runtime rebuilds the view from the model. Rebuilds are cheap and safe by design:
- **Widget identity is structural.** A widget keeps its id across rebuilds, reorders, and hot reloads, so engine-owned state — scroll offsets, text carets, focus — survives. List items carry `key` (or `global-key` for items that move between containers) to keep identity through reorders. Unkeyed same-kind siblings take positional identity (sibling index), so an `<if>` that inserts or removes an earlier same-kind sibling re-disambiguates the trailing ones — engine-owned state like carets and scroll can hop; keyed items and keyed ancestors hold identity.
- **The source wins.** Engine-retained state (a scroll offset, a toggle) survives rebuilds until the model asserts a different value; then the model's value applies. This is why controlled patterns echo runtime-applied values back through the model — see [State & Data Flow](/state).
- **Errors degrade, they never crash.** A failing `update` arm is caught, recorded in a bounded error ring (visible in [automation](/automation) snapshots as `dispatch_errors=`), and the app keeps running.
## Hot reload in development
With `watch_path` set, the runtime watches the `.native` file while the app runs. Edits apply within about two seconds, preserving model state and widget identity. Parse failures keep the last good view on screen and record a diagnostic (`app_state.markup_diagnostic` carries line, column, and message).
## Compile the markup for release
In release builds the markup compiles at comptime — no parser in the binary, and markup or binding mistakes become compile errors with line and column:
```zig
const dev = @import("builtin").mode == .Debug;
const App = native_sdk.UiAppWithFeatures(Model, Msg, .{ .runtime_markup = dev });
const CompiledView = canvas.CompiledMarkupView(Model, Msg, @embedFile("app.native"));
// options:
.view = CompiledView.build,
.markup = if (dev) .{ .source = app_markup, .watch_path = "src/app.native", .io = init.io } else null,
```
Both engines produce the identical widget tree — same structural ids, same typed handler table — so tests, automation scripts, and goldens hold across dev and release.
## Hybrid views: a Zig root composing markup fragments
Compiled markup views compose the other way too: a hand-written Zig builder root can build markup fragments as ordinary children. This is the pattern for any UI that mixes custom Zig panes with declarative markup — the root places what the closed grammar cannot express (a scaled `ui.paragraph` display block, a `.band`-series `ui.chart`, per-row native context menus), and the markup keeps everything it can:
```zig
const CompiledHeaderView = canvas.CompiledMarkupView(Model, Msg, @embedFile("header.native"));
pub fn rootView(ui: *Ui, model: *const Model) Ui.Node {
return ui.column(.{ .gap = 12, .grow = 1 }, .{
CompiledHeaderView.build(ui, model), // the markup fragment, as a child
ui.paragraph(.{}, &.{
.{ .text = model.readout(ui.arena), .monospace = true, .scale = 1.6 },
}),
});
}
// options: .view = rootView,
```
`examples/calculator` is the smallest live reference (`CompiledKeypadView.build(ui, model)` inside a hand-written root); `system-monitor`, `soundboard`, and `deck` use the same shape. Widget ids, handlers, and dispatch are identical to a pure-markup tree, so tests and automation address the fragment's widgets the usual way.
A Zig-root app keeps dev-time hot reload for its embedded fragments too: build with `UiAppWithFeatures(Model, Msg, .{ .runtime_markup = dev_markup_reload })` and pass `.markup = .{ .source = ..., .watch_path = "src/header.native", .io = init.io }` gated on `builtin.mode == .Debug` (`null` otherwise) — the wiring in `examples/notes`. Debug builds then reload edits to the watched file in place; release builds compile the runtime engine out entirely.
## Side effects
`update` stays pure by routing anything asynchronous — subprocesses, HTTP, file persistence, timers, clipboard — through the effects channel, and results come back as ordinary messages. In a TypeScript core, effects are `Cmd` data returned from `update` and recurring timers are declared `Sub` data — see [TypeScript Cores: Effects](/typescript#effects-are-cmd-data). In a Zig core, declare `.update_fx` instead of `.update` and spawn from message arms; boot-time work goes in `.init_fx`, which runs exactly once before the first paint. See [Native UI: Effects](/native-ui#effects).
## Dropping down
`UiApp` is a layer over the lower-level `App`/`Runtime` pair, which any app can use directly — for custom lifecycle callbacks, imperative window and view management, or embedding [web content](/frontend). The [App & Runtime](/runtime) reference documents that layer, and [Embedded App](/embed) covers driving the runtime from an existing host (including iOS and Android).
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("app-zon");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+413
View File
@@ -0,0 +1,413 @@
# Config
The `app.zon` manifest declares app metadata, permissions, security rules, window layout, and packaging inputs. It is read by the CLI and tooling at build, package, and validation time.
## Example: native-rendered app
The manifest `native init` generates — identity, one shell window with a GPU surface view, and the minimal permission set:
```zig:app.zon
.{
.id = "dev.native_sdk.my-app",
.name = "my-app",
.display_name = "My App",
.description = "A counter that lives in one native window.",
.version = "0.1.0",
.icons = .{"assets/icon.png"},
.platforms = .{"macos"},
.permissions = .{ "view", "command" },
.capabilities = .{ "native_views", "gpu_surfaces" },
.shell = .{
.windows = .{
.{
.label = "main",
.title = "My App",
.width = 480,
.height = 320,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "main-canvas", .kind = "gpu_surface", .fill = true, .role = "Counter canvas", .accessibility_label = "Counter", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
```
## Example: app with web content, menus, and shortcuts
A fuller manifest for an app that also [embeds web content](/frontend) and declares commands, shortcuts, menus, and packaging metadata:
```zig:app.zon
.{
.id = "dev.native_sdk",
.name = "native-sdk",
.display_name = "native-sdk",
.version = "0.1.0",
.icons = .{"assets/icon.png"},
.platforms = .{ "macos" },
.permissions = .{ "command", "view", "dialog", "window", "clipboard", "credentials" },
.capabilities = .{ "webview", "js_bridge", "native_views", "gpu_surfaces", "menus", "shortcuts", "dialog", "clipboard", "credentials" },
.bridge = .{
.commands = .{
.{ .name = "native.ping", .origins = .{ "zero://app" } },
.{ .name = "native-sdk.window.create", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.command.invoke", .permissions = .{ "command" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.view.list", .permissions = .{ "view" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.dialog.showMessage", .permissions = .{ "dialog" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.clipboard.readText", .permissions = .{ "clipboard" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.credentials.get", .permissions = .{ "credentials" }, .origins = .{ "zero://app" } },
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "http://127.0.0.1:5173" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
.windows = .{
.{ .label = "main", .title = "native-sdk", .width = 720, .height = 480, .restore_state = true },
},
.commands = .{
.{ .id = "command.palette", .title = "Command Palette" },
.{ .id = "app.reload", .title = "Reload" },
},
.shortcuts = .{
.{ .id = "command.palette", .key = "p", .modifiers = .{ "primary", "shift" } },
},
.menus = .{
.{
.title = "View",
.items = .{
.{ .label = "Command Palette", .command = "command.palette", .key = "p", .modifiers = .{ "primary", "shift" } },
.{ .separator = true },
.{ .label = "Reload", .command = "app.reload", .key = "r", .modifiers = .{ "primary" } },
},
},
},
.file_associations = .{
.{
.name = "Markdown Document",
.extensions = .{ "md", ".markdown" },
.mime_types = .{ "text/markdown" },
.icon = "assets/markdown.icns",
},
},
.url_schemes = .{
.{ .scheme = "native-sdk" },
},
}
```
## Fields
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>id</code></td>
<td>Reverse-DNS bundle identifier (e.g. <code>com.example.myapp</code>)</td>
</tr>
<tr>
<td><code>name</code></td>
<td>Short machine name</td>
</tr>
<tr>
<td><code>display_name</code></td>
<td>Human-readable app name — the application menu, Dock, app switcher, and About panel all use it, in dev runs and packaged bundles alike</td>
</tr>
<tr>
<td><code>description</code></td>
<td>Optional one-line description shown in the About panel (max 256 bytes, single line)</td>
</tr>
<tr>
<td><code>version</code></td>
<td>Semver version string — also the version the About panel shows</td>
</tr>
<tr>
<td><code>icons</code></td>
<td>The app icon: one square <code>.png</code> (1:1, ideally 1024x1024) or <code>.svg</code> source that packaging turns into every platform's icon artifacts &mdash; the macOS <code>.icns</code> gets the platform's rounded-rect icon shape applied automatically. A prebuilt <code>.icns</code>/<code>.ico</code> entry ships untouched for its platform instead</td>
</tr>
<tr>
<td><code>platforms</code></td>
<td>Target platforms: <code>macos</code>, <code>linux</code>, <code>windows</code></td>
</tr>
<tr>
<td><code>permissions</code></td>
<td>Runtime permissions (see <a href="/security">Security</a>)</td>
</tr>
<tr>
<td><code>capabilities</code></td>
<td>Feature declarations (see <a href="/security">Security</a>)</td>
</tr>
<tr>
<td><code>bridge</code></td>
<td>Bridge command policies (see <a href="/bridge">Bridge</a>)</td>
</tr>
<tr>
<td><code>security</code></td>
<td>Navigation and external link policies (see <a href="/security">Security</a>)</td>
</tr>
<tr>
<td><code>web_engine</code></td>
<td><code>system</code> or <code>chromium</code>; Chromium is currently supported for macOS builds (see <a href="/web-engines">Web Engines</a>)</td>
</tr>
<tr>
<td><code>webview_layer</code></td>
<td><code>auto</code> (default), <code>include</code>, or <code>exclude</code> — whether the build ships the embedded web layer (see below)</td>
</tr>
<tr>
<td><code>theme</code></td>
<td>Built-in theme pack: <code>house</code> (default) or <code>geist</code>; an unknown name is a build/check error (see <a href="/theming">Theming</a>)</td>
</tr>
<tr>
<td><code>theme_accent</code></td>
<td>Optional <code>#rrggbb</code> accent layered over the pack — recolors the accent, its knockout ink, the focus ring, and the slider active range together; skipped under high contrast; a malformed value is a build/check error</td>
</tr>
<tr>
<td><code>cef</code></td>
<td>CEF runtime config for Chromium apps: <code>dir</code> and <code>auto_install</code></td>
</tr>
<tr>
<td><code>assets</code></td>
<td>Launch-registered assets for TypeScript-core apps: <code>images</code> entries (<code>.&#123; .id = 1, .path = "assets/art/cover.jpg" &#125;</code>) are read once at launch and registered on the installing frame; the <code>id</code> is the <code>ImageId</code> markup avatar bindings reference</td>
</tr>
<tr>
<td><code>windows</code></td>
<td>Window definitions (see <a href="/windows">Windows</a>)</td>
</tr>
<tr>
<td><code>shell</code></td>
<td>Native-first window and view tree declarations</td>
</tr>
<tr>
<td><code>commands</code></td>
<td>Shared command metadata for menus, shortcuts, native controls, tray items, and bridge calls</td>
</tr>
<tr>
<td><code>shortcuts</code></td>
<td>Keyboard shortcuts delivered as <code>shortcut</code> events (see <a href="/keyboard-shortcuts">Keyboard Shortcuts</a>)</td>
</tr>
<tr>
<td><code>menus</code></td>
<td>Native menu declarations delivered through the command event path (see <a href="/menus">Menus</a>)</td>
</tr>
<tr>
<td><code>file_associations</code></td>
<td>File type registration metadata for packaging</td>
</tr>
<tr>
<td><code>url_schemes</code></td>
<td>Custom URL scheme registration metadata for packaging</td>
</tr>
<tr>
<td><code>frontend</code></td>
<td>Frontend build/dev config (see <a href="/frontend">Frontend Projects</a>)</td>
</tr>
</tbody>
</table>
## `webview_layer`
Under the default `auto`, the build infers whether to ship the embedded web layer from what the app declares: `"webview"` in `.capabilities`, a `.frontend` block, a `.shell` webview view, or a web engine resolved to Chromium (from `.web_engine` or the `-Dweb-engine`/`--web-engine` flags) each count as web intent, and an app with none of them builds native-only. `include` forces the layer into a build that declares nothing yet; `exclude` promises a native-only app. The `-Dweb-layer` build option overrides the manifest setting for one build.
A native-only build carries the platform consequences with it: on Windows the executable never references `WebView2Loader.dll` and no loader ships in the package; on Linux the host compiles and links without WebKitGTK entirely, so building needs no WebKitGTK development package and users need no `libwebkitgtk` installed at runtime.
`exclude` alongside any web declaration is a contradiction, refused with a teaching error at validate, build, and package time instead of shipping an app whose declared webviews cannot work:
```zig
.capabilities = .{"webview"},
.webview_layer = "exclude", // rejected: remove the web declarations or drop the exclude
```
## `shell`
The optional `shell` block declares native-first windows with explicit view trees. Existing `windows` entries remain the simple compatibility path; `shell.windows` is the richer contract for native chrome, native controls, WebViews, and GPU surfaces.
Tooling parses and validates the schema. Runtime code can return the same shape from `App.scene_fn`, materialize a parsed shell window with `runtime.createShellWindow(...)`, or attach a shell view list to an existing window with `runtime.createShellViews(...)`; platform hosts implement view kinds progressively, so unsupported native kinds fail at runtime with an explicit unsupported error until that backend grows support.
When an app uses both `windows` and `shell.windows`, labels must stay unique across both lists. Use `windows` for the simple compatibility path or `shell.windows` for native-first structure; do not define two window entries with the same label.
For a scene-first app — a `UiApp` passing its Zig scene (`shell_scene`) to the runner — the scene is authoritative at runtime: it re-applies size, title, and views when it loads. `app.zon`'s `.shell.windows[0]` exists because the host creates the startup window before the scene loads, and create-time-only properties must come from the manifest: `titlebar` chrome, `min_width`/`min_height` floors, and the show mode (canvas-first windows are created hidden and shown after the first frame presents). The numbers appearing in both places is by design — edit the scene for anything that can change after create (size, title, views), and the manifest for create-time chrome and floors.
```zig
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Acme",
.width = 1100,
.height = 760,
.views = .{
.{ .label = "toolbar", .kind = "toolbar", .edge = "top", .height = 44, .role = "toolbar" },
.{ .label = "body", .kind = "split", .fill = true, .axis = "row" },
.{ .label = "sidebar", .kind = "sidebar", .parent = "body", .width = 280, .min_width = 220, .max_width = 360 },
.{ .label = "sidebar-stack", .kind = "stack", .parent = "sidebar", .x = 16, .y = 16, .width = 220, .height = 120, .axis = "column" },
.{ .label = "sidebar-live", .kind = "checkbox", .parent = "sidebar-stack", .accessibility_label = "Toggle live updates", .text = "Live updates" },
.{ .label = "content", .kind = "webview", .parent = "body", .url = "zero://app/index.html", .fill = true },
.{ .label = "canvas", .kind = "gpu_surface", .parent = "body", .width = 480, .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
.{ .label = "status", .kind = "statusbar", .edge = "bottom", .height = 24, .text = "Ready" },
},
},
},
},
```
Each window takes a `label` plus optional `title`, `width`, `height`, `x`, `y`, `resizable`, `restore_state`, `restore_policy` (`clamp_to_visible_screen` or `center_on_primary`), `min_width`/`min_height` (a content min-size floor the window itself enforces — macOS `contentMinSize`; the first shell window's declaration threads through the startup create like `titlebar`, negative values are a manifest error, 0 means no floor), and `titlebar` (`standard`, `hidden_inset`, `hidden_inset_tall` — the tall variant centers macOS's traffic lights in the 52pt unified band for toolbar-height headers — or `chromeless`, the fully-skinned opt-in that removes all OS chrome including the system buttons; only for apps that draw their own working window controls, see `examples/deck`). `titlebar = "hidden_inset"` hides the titlebar and extends content under it (macOS keeps the traffic lights) — the first shell window's declaration threads through the STARTUP window create, so the main window's chrome is right from the first frame; the app's own header then takes over dragging and inset padding through the `window-drag` attribute and the `on_chrome` hook (see <a href="/native-ui">Native UI</a>). Platforms without the concept keep standard chrome. The same `titlebar` field is accepted on top-level `windows` entries.
Supported `kind` values are `webview`, `toolbar`, `titlebar_accessory`, `sidebar`, `statusbar`, `split`, `stack`, `button`, `icon_button`, `list_item`, `checkbox`, `toggle`, `segmented_control`, `text_field`, `search_field`, `label`, `spacer`, `gpu_surface`, and `progress_indicator`.
Each view has a required `label` and `kind`. WebView views require `url`. Optional layout fields are `parent`, `edge`, `axis`, `x`, `y`, `width`, `height`, `min_width`, `min_height`, `max_width`, `max_height`, `fill`, and `layer`. Optional behavior and accessibility metadata are `visible`, `enabled`, `role`, `accessibility_label`, `text`, and `command`. `gpu_surface` views may also set `gpu_backend`, `gpu_pixel_format`, `gpu_present_mode`, `gpu_alpha_mode`, `gpu_color_space`, and `gpu_vsync`; those fields are rejected on non-GPU view kinds. The currently implemented macOS system-WebView backend uses `metal`, `bgra8_unorm`, `timer`, `opaque`, `srgb`, and `gpu_vsync = true`. `gpu_backend` also accepts `software` (the CPU reference-renderer path); on Linux and Windows system-WebView hosts any declared backend falls back to software presentation rather than erroring. The `axis` field accepts `row` or `column` on parent containers such as `toolbar`, `sidebar`, `split`, and `stack`; it defaults to `row`.
`createShellWindow` and `createShellViews` dock `edge` views against the remaining window content, let one or more top-level `fill` views use the final remaining rectangle, clamp resolved frames with any min/max size fields, and lay out parented controls such as toolbar buttons with small native defaults when explicit `x`, `y`, `width`, or `height` values are omitted. Parent containers flow omitted child positions horizontally with `axis = "row"` and vertically with `axis = "column"`. `split` containers use the same axis without inner spacing, so fixed-size children can sit beside a `fill` child. The runtime keeps the shell view slice as a layout binding and reapplies it when the window is resized, so pass data that lives for the lifetime of the window.
## `commands`
The optional `commands` list declares shared command metadata. The runtime still dispatches by command id, but this section gives menus, shortcuts, native controls, tray items, and bridge callers one manifest-level source of truth for user-facing labels and default state:
```zig
.commands = .{
.{ .id = "app.refresh", .title = "Refresh" },
.{ .id = "app.sidebar.toggle", .title = "Sidebar", .checked = true },
.{ .id = "app.sync", .title = "Sync", .enabled = false },
},
```
`id` is required and must be unique. `title` defaults to an empty string. `enabled` defaults to `true`; `checked` defaults to `false`.
An app can define up to 256 commands. Command ids can be up to 128 bytes and titles can be up to 128 bytes.
Generated runners load manifest commands into `RuntimeOptions.commands`. Native code can read the active catalog with `runtime.listCommands(...)`, and trusted WebView code can read it with `window.zero.commands.list()` when the built-in command bridge allows it. Use the catalog to keep menus, shortcuts, toolbar controls, tray items, and bridge callers aligned with the same command ids.
## `shortcuts`
The optional `shortcuts` list defines app-level keyboard shortcuts. Generated runners load these automatically:
```zig
.shortcuts = .{
.{ .id = "command.palette", .key = "p", .modifiers = .{ "primary", "shift" } },
.{ .id = "reload", .key = "r", .modifiers = .{ "primary" } },
},
```
`id` is the event identifier. `key` accepts letters, digits, the unshifted punctuation keys `=`, `-`, `,`, `.`, `/`, `;`, `'`, `[`, `]`, `\`, and `` ` ``, or a named key. `modifiers` accepts `primary`, `command`, `control`, `option`, `alt`, and `shift`.
Single-character keys and text-entry keys (`enter`, `tab`, `space`, and `backspace`) require at least one modifier.
An app can define up to 64 shortcuts. Shortcut ids can be up to 64 bytes and keys can be up to 32 bytes.
Chromium builds are currently macOS-only; use the Linux system WebView backend when an app needs native shortcut events on Linux.
## `menus`
The optional `menus` list defines native app menus. Generated runners load these automatically:
```zig
.menus = .{
.{
.title = "View",
.items = .{
.{ .label = "Refresh", .command = "app.refresh", .key = "r", .modifiers = .{ "primary" } },
.{ .separator = true },
.{ .label = "Sidebar", .command = "app.sidebar.toggle", .checked = true },
},
},
},
```
Each menu has a `title` and optional `items`. Command-backed items require `label` and `command`. Optional fields are `key`, `modifiers`, `separator`, `enabled`, and `checked`. `modifiers` accepts the same values as shortcuts.
An app can define up to 16 menus and 128 total menu items. Menu titles can be up to 64 bytes, item labels up to 128 bytes, command ids up to 128 bytes, and keys up to 32 bytes.
## File associations and URL schemes
The optional `file_associations` and `url_schemes` lists declare packaging metadata for document types and custom protocols:
```zig
.file_associations = .{
.{
.name = "Markdown Document",
.role = "editor",
.extensions = .{ "md", ".markdown" },
.mime_types = .{ "text/markdown" },
.icon = "assets/markdown.icns",
},
},
.url_schemes = .{
.{ .scheme = "acme-notes" },
},
```
File associations require a `name` and at least one `extensions` or `mime_types` entry. Extensions may include a leading dot, but packaging tools treat the extension as the same value either way. `icon` is optional and must be a relative path.
URL schemes require a lowercase custom `scheme`. Reserved schemes such as `http`, `https`, and `file` are rejected.
`role` defaults to `viewer` and accepts `viewer`, `editor`, `shell`, or `none`. Package generation emits this metadata into macOS `Info.plist` document/protocol declarations, Linux desktop and shared MIME metadata, and a Windows per-user registration script.
## `frontend.dev`
The optional `frontend.dev` block configures the managed dev server for `native dev` and `zig build dev`:
```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" },
.ready_path = "/",
.timeout_ms = 30000,
},
},
```
<table>
<thead>
<tr>
<th>Field</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>url</code></td>
<td>Dev server URL to load in the WebView during development</td>
</tr>
<tr>
<td><code>command</code></td>
<td>Command to start the dev server (spawned as a child process)</td>
</tr>
<tr>
<td><code>ready_path</code></td>
<td>HTTP path to poll until the dev server is ready (default <code>/</code>)</td>
</tr>
<tr>
<td><code>timeout_ms</code></td>
<td>Milliseconds to wait for the dev server before failing (default <code>30000</code>)</td>
</tr>
</tbody>
</table>
## Validation
```bash
native validate app.zon
native doctor --manifest app.zon --strict
```
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("automation");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+268
View File
@@ -0,0 +1,268 @@
# Automation
The automation server exposes runtime state and accepts commands via a file-based protocol. A running app publishes an accessibility snapshot — every widget with its id, role, name, bounds, and state — and accepts scripted clicks, keys, drags, and assertions against it. Use it for integration testing, CI smoke tests, inspecting running apps, and letting AI agents verify their own UI work.
## Enabling automation
Build with the automation flag (`native` verbs forward `-D` flags to the underlying `zig build`):
```bash
native build -Dautomation=true
```
In your runner, pass an `automation.Server` to `RuntimeOptions`:
```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,
});
```
The default directory is `.zig-cache/native-sdk-automation`.
## File protocol
When the runtime publishes a snapshot, it writes these files to the automation directory:
<table>
<thead>
<tr>
<th>File</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>snapshot.txt</code></td>
<td>Runtime state: source kind, window metadata, native/WebView metadata including role, accessibility label, text, and focus state, <code>ready=true/false</code>, and <code>markup_watch=armed|off</code> in the header — whether the markup hot-reload watch is armed (only in builds where the app wired <code>.markup</code> with a <code>watch_path</code> and <code>io</code>, or registered compiled fragments through <code>fragment_watch</code> — i.e. Debug dev builds)</td>
</tr>
<tr>
<td><code>accessibility.txt</code></td>
<td>Accessibility tree summary with native view roles and accessible names. An explicit <code>label=</code> REPLACES the visible text as the accessible name — snapshot greps and screen readers see the label, not the text, so don't label an element whose visible text your assertions grep for.</td>
</tr>
<tr>
<td><code>windows.txt</code></td>
<td>Window list: <code>window @w{"{id}"} "{"{title}"}" focused={"{bool}"}</code> per line</td>
</tr>
<tr>
<td><code>screenshot-&lt;view-label&gt;.png</code></td>
<td>Deterministic PNG of a <code>gpu_surface</code> view, rendered through the CPU reference renderer on <code>screenshot &lt;view-label&gt; [scale]</code></td>
</tr>
<tr>
<td><code>command-&lt;n&gt;.txt</code></td>
<td>Command queue: one entry per command, written by the CLI, consumed oldest-first by the runtime (which deletes the entry as its consumption ack)</td>
</tr>
<tr>
<td><code>bridge-response.txt</code></td>
<td>JSON response from the last bridge command</td>
</tr>
<tr>
<td><code>provenance.txt</code></td>
<td>Response to the last <code>provenance</code> command: where a live widget was authored (file, byte span, line:column, template chain, iteration keys)</td>
</tr>
</tbody>
</table>
## Commands
The runtime watches the command queue and processes these actions:
<table>
<thead>
<tr>
<th>Action</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>reload</code></td>
<td>Reload the WebView source</td>
</tr>
<tr>
<td><code>wait</code></td>
<td>Block until the snapshot shows <code>ready=true</code></td>
</tr>
<tr>
<td><code>resize &lt;width&gt; &lt;height&gt; [&lt;scale&gt;]</code></td>
<td>Dispatch a main-window resize event and relayout native/WebView surfaces</td>
</tr>
<tr>
<td><code>provenance &lt;view-label&gt; (&lt;widget-id&gt; | at &lt;x&gt; &lt;y&gt;)</code></td>
<td>Report where a live widget was authored into <code>provenance.txt</code>: markup file, byte span, line:column, template use-site chain, and iteration keys. Point queries hit-test view-local coordinates.</td>
</tr>
<tr>
<td><code>bridge &lt;json&gt;</code></td>
<td>Send a bridge command with origin <code>zero://inline</code></td>
</tr>
<tr>
<td><code>menu-command &lt;id&gt;</code></td>
<td>Dispatch a menu command event for the main window</td>
</tr>
<tr>
<td><code>native-command &lt;id&gt; [&lt;view-label&gt;]</code></td>
<td>Dispatch a native view command event for the main window</td>
</tr>
<tr>
<td><code>widget-action &lt;view-label&gt; &lt;widget-id&gt; &lt;action&gt; [&lt;value&gt;]</code></td>
<td>Invoke a retained canvas widget action</td>
</tr>
<tr>
<td><code>widget-click &lt;view-label&gt; &lt;widget-id&gt;</code></td>
<td>Dispatch pointer down/up at a retained canvas widget</td>
</tr>
<tr>
<td><code>widget-hold &lt;view-label&gt; &lt;widget-id&gt;</code></td>
<td>Press-and-hold: pointer down, the reserved hold timer fired, then the suppressed release — the widget's <code>on_hold</code> Msg through the real gesture path</td>
</tr>
<tr>
<td><code>widget-context-press &lt;view-label&gt; &lt;widget-id&gt;</code></td>
<td>Secondary click (right/ctrl-click): presents the widget's context menu, or dispatches <code>on_hold</code> immediately when the route has none</td>
</tr>
<tr>
<td><code>widget-context-menu &lt;view-label&gt; &lt;widget-id&gt; &lt;item-index&gt;</code></td>
<td>Invoke a declared context-menu item by 0-based index (as the snapshot's <code>context_menu=[...]</code> lists them) — the selection dispatches as the same <code>context_menu_action</code> platform event a real pick produces; presentation is skipped because the OS menu's tracking loop cannot be driven. Refuses undeclared menus, out-of-range indices, separators, and disabled items by name</td>
</tr>
<tr>
<td><code>widget-drag &lt;view-label&gt; &lt;widget-id&gt; &lt;start-x-ratio&gt; &lt;end-x-ratio&gt; [&lt;start-y-ratio&gt; &lt;end-y-ratio&gt;]</code></td>
<td>Dispatch pointer down/drag/up across a retained canvas widget</td>
</tr>
<tr>
<td><code>widget-wheel &lt;view-label&gt; &lt;widget-id&gt; &lt;delta-y&gt;</code></td>
<td>Dispatch wheel input at a retained canvas widget</td>
</tr>
<tr>
<td><code>widget-key &lt;view-label&gt; &lt;key&gt; [&lt;text&gt;]</code></td>
<td>Dispatch key input to the focused retained canvas widget</td>
</tr>
<tr>
<td><code>shortcut &lt;id&gt;</code></td>
<td>Dispatch a shortcut command event for the main window</td>
</tr>
<tr>
<td><code>tray-action &lt;item-id&gt;</code></td>
<td>Select a status-item dropdown row (ids from the snapshot's <code>tray-item #id</code> lines)</td>
</tr>
<tr>
<td><code>focus &lt;view-label&gt;</code></td>
<td>Focus a native or WebView-backed view in the main window</td>
</tr>
<tr>
<td><code>focus-next</code> / <code>focus-previous</code></td>
<td>Move focus through visible, enabled views in runtime order</td>
</tr>
<tr>
<td><code>profile &lt;on|off&gt;</code></td>
<td>Toggle per-stage frame timing. While on, the snapshot carries a <code>frame_profile</code> line with rolling p50/p90/max microseconds per pipeline stage (<code>rebuild</code>, <code>layout</code>, <code>reconcile</code>, <code>emit</code>, <code>a11y</code>, <code>plan</code>, <code>patch</code>, <code>encode</code>, <code>present</code>, <code>host_decode</code>, <code>host_draw</code>)</td>
</tr>
</tbody>
</table>
Commands queue as `command-<n>.txt` entries: each `native automate <command>` claims the next sequence number exclusively (rapid back-to-back invocations can never overwrite each other), the app consumes one entry per presented frame in strict arrival order, and deleting the entry is the consumption ack. The queue is bounded to a handful of entries — a writer finding it full retries until the app drains a slot and fails loudly (non-zero exit) if it never does. `native automate <command>` prints `delivered <action> -> <dir>` only after the app consumed its entry, so a dead or frozen app is always a loud failure, never a silently dropped command.
Widget verbs and `screenshot` address a `gpu_surface` view by its label across ALL open windows — snapshots enumerate every window's views and widgets, so a model-declared secondary window's canvas (a settings window) is drivable and capturable exactly like the main one, with no window argument.
## CLI usage
The `native automate` subcommand interacts with the automation directory:
```bash
# Wait for the app to be ready (polls snapshot.txt for ready=true)
native automate wait
# Assert on the snapshot: each argument is a regex that must match
# (polls up to --timeout-ms, default 30000; --absent inverts)
native automate assert 'gpu_nonblank=true' 'role=button name="Reset"'
native automate assert --absent 'error event='
# List running automation-enabled apps
native automate list
# Dump the current snapshot
native automate snapshot
# Render a gpu_surface view to screenshot-main-canvas.png
native automate screenshot main-canvas
# Reload the WebView
native automate reload
# Resize the main window surface
native automate resize 900 640
# Dispatch command-source events
native automate menu-command app.refresh
native automate native-command app.refresh refresh-button
native automate shortcut app.refresh
# Drive retained canvas widgets
native automate widget-action canvas 2 press
native automate widget-click canvas 3
native automate widget-hold canvas 3 # press-and-hold (on_hold)
native automate widget-context-press canvas 3 # right-click (presents the menu)
native automate widget-context-menu canvas 3 1 # pick declared menu item 1
# Drive focus between native controls and WebViews
native automate focus refresh-button
native automate focus-next
native automate focus-previous
# Toggle per-stage frame timing; the snapshot then carries a
# frame_profile line (rolling p50/p90/max us per pipeline stage)
native automate profile on
native automate snapshot | grep -o 'frame_profile.*'
native automate profile off
# Send a bridge command and get the response
native automate bridge '{"id":"1","command":"native.ping","payload":{"source":"automation"}}'
```
## Testing with automation
The WebView and native-shell smoke build steps demonstrate full automation test flows:
1. Build and start the app with `-Dautomation=true`
2. Run `native automate wait` to block until the app is ready
3. Run `native automate snapshot` to verify window, source, and native/WebView metadata
4. Run `native automate resize ...`, `native automate bridge '...'`, focus traversal, or command-source actions such as `native automate native-command ...`
5. Verify bridge responses, relayout bounds, focus state, and updated snapshots
```bash
zig build test-webview-smoke -Dplatform=macos
```
## Write-back
Because views are data, automation can go the other way too: select a widget in the running app, jump to the markup that authored it, and write an edit back into the source file — the app's own hot-reload watch picks the change up and repaints.
The read half is `provenance`. Every markup-built widget's structural id maps back to its authored source, captured at view build time: the file, the node's byte span and line:column, the template instantiation chain (a widget inside a template reports both its definition site and every `<use>` that put it there), and the iteration keys that say which `<for>` row it is. Widgets built with the Zig builder report `authored=zig` honestly — write-back edits markup files only.
```bash
# Where does this widget come from? (id from the snapshot, or hit-test a point)
native automate provenance kanban-canvas 6624116744891006388
native automate provenance kanban-canvas at 760 30
```
The write half is `edit`: typed, minimal-diff operations on the file the widget came from. An operation changes only bytes inside the target node's span — whitespace, comments, attribute order, and every other node survive byte for byte, proven by reparsing and diffing the parse trees before anything is written.
```bash
native automate edit kanban-canvas 6624116744891006388 set-text "Add task"
native automate edit kanban-canvas 6624116744891006388 set-attr variant secondary
native automate edit kanban-canvas 6624116744891006388 remove-attr variant
```
Edits refuse rather than guess: a widget authored in Zig, a file that changed on disk since the app loaded it (the provenance response carries the loaded bytes' hash, so concurrent edits are never clobbered), or an edit that would fail markup validation all stop with a teaching error and leave the file untouched. A successful edit needs no reload command — the markup watch (`MarkupOptions.watch_path`, a dev/Debug feature) reloads within its poll interval, so `native automate assert` on the snapshot is the way to await the repaint.
`zig build test-writeback-smoke` (macOS) drives the whole loop against the kanban example: query provenance, flip the button label through the verb, assert the repaint, verify the byte-exact diff, and flip it back.
## Custom directory
Pass a custom path to `automation.Server.init()`:
```zig
const server = native_sdk.automation.Server.init(io, "/tmp/my-app-automation", "My App");
```
The CLI reads from the default `.zig-cache/native-sdk-automation` unless you specify a directory via the automation subcommand.
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("bridge/builtin-commands");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,498 @@
# Builtin Commands
The Native SDK provides built-in bridge commands for app command routing, window management, generic native views, layered WebViews, platform support queries, native dialogs, selected OS capabilities, and credential storage. These are controlled by the `builtin_bridge` policy in `RuntimeOptions`, separate from app-defined bridge handlers.
## Command Routing
<table>
<thead>
<tr>
<th>Command</th>
<th>Required permission</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>native-sdk.command.invoke</code></td>
<td><code>command</code></td>
<td>Dispatch an <code>Event.command</code> into the app runtime from the calling WebView</td>
</tr>
<tr>
<td><code>native-sdk.command.list</code></td>
<td><code>command</code></td>
<td>List the manifest command catalog loaded into the runtime</td>
</tr>
</tbody>
</table>
Command routing is available through `window.zero.commands.invoke(...)` when `js_window_api` is `true`. The manifest command catalog is available through `window.zero.commands.list()`. When runtime permissions are configured, command helpers require the `command` permission; the legacy `window` permission is still accepted for compatibility. The runtime emits a `CommandEvent` with `source = .bridge`, the calling `window_id`, and the calling view label when the request comes from a named child WebView.
Native controls can also bind a `command` when created with `runtime.createView(...)` or `window.zero.views.create(...)`. On macOS, Linux, and Windows system WebView builds, native button-style controls dispatch the command with the native `window_id` and the originating view label. Controls inside a toolbar report `source = .toolbar`; other native controls report `source = .native_view`.
## Platform Support Commands
<table>
<thead>
<tr>
<th>Command</th>
<th>Required permission</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>native-sdk.platform.supports</code></td>
<td><code>window</code></td>
<td>Return whether the current platform and web engine support a feature</td>
</tr>
</tbody>
</table>
Platform support queries are available through `window.zero.platform.supports(...)` when `js_window_api` is `true`. The command accepts feature names from `PlatformFeature`, including `main_webview`, `child_webviews`, `native_views`, `native_control_commands`, `menus`, `tray`, `shortcuts`, `dialogs`, `clipboard_text`, `clipboard_rich_data`, `open_url`, `reveal_path`, `notifications`, `recent_documents`, `credentials`, `file_drops`, `app_activation_events`, and `gpu_surfaces`. JavaScript callers can also use camelCase aliases such as `mainWebView`, `nativeControlCommands`, `clipboardRichData`, `recentDocuments`, `fileDrops`, `appActivationEvents`, and `gpuSurfaces`. The helper accepts either a string or a selector object with `feature` or `name`; raw bridge payloads may use the same fields. Use an explicit `builtin_bridge` policy when you want per-command origin lists.
## Window commands
<table>
<thead>
<tr>
<th>Command</th>
<th>Required permission</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>native-sdk.window.list</code></td>
<td><code>window</code></td>
<td>List all open windows</td>
</tr>
<tr>
<td><code>native-sdk.window.create</code></td>
<td><code>window</code></td>
<td>Create a new window</td>
</tr>
<tr>
<td><code>native-sdk.window.focus</code></td>
<td><code>window</code></td>
<td>Focus a window by ID</td>
</tr>
<tr>
<td><code>native-sdk.window.close</code></td>
<td><code>window</code></td>
<td>Close a window by ID</td>
</tr>
</tbody>
</table>
Window commands are available through `window.zero.windows.*` when `js_window_api` is `true`, but the runtime still checks the request origin and the `window` permission when permissions are configured. Use an explicit `builtin_bridge` policy when you want per-command origin lists.
## View Commands
<table>
<thead>
<tr>
<th>Command</th>
<th>Required permission</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>native-sdk.view.create</code></td>
<td><code>view</code></td>
<td>Create a generic native view or WebView-backed view in the calling window</td>
</tr>
<tr>
<td><code>native-sdk.view.list</code></td>
<td><code>view</code></td>
<td>List generic views and WebViews in the calling window</td>
</tr>
<tr>
<td><code>native-sdk.view.update</code></td>
<td><code>view</code></td>
<td>Patch frame, layer, visibility, enabled state, role, text, command, or WebView URL</td>
</tr>
<tr>
<td><code>native-sdk.view.setFrame</code></td>
<td><code>view</code></td>
<td>Move or resize a view</td>
</tr>
<tr>
<td><code>native-sdk.view.setVisible</code></td>
<td><code>view</code></td>
<td>Show or hide a view</td>
</tr>
<tr>
<td><code>native-sdk.view.focus</code></td>
<td><code>view</code></td>
<td>Focus a view when the backend supports native focus for that kind</td>
</tr>
<tr>
<td><code>native-sdk.view.focusNext</code> / <code>native-sdk.view.focusPrevious</code></td>
<td><code>view</code></td>
<td>Move focus through visible, enabled native controls and WebView-backed views</td>
</tr>
<tr>
<td><code>native-sdk.view.close</code></td>
<td><code>view</code></td>
<td>Close a generic view or child WebView</td>
</tr>
</tbody>
</table>
View commands are available through `window.zero.views.*` when `js_window_api` is `true`. They use origin checks and the `view` permission when runtime permissions are configured; the legacy `window` permission is still accepted for compatibility. `windowId` must match the calling window when provided. The `update(label, patch)`, `focus(label)`, and `close(label)` helpers accept string labels for the calling window; pass selector objects when you need an explicit `windowId`. View responses include frame, layer, visibility, enabled, focus, role, accessibility label, text, command, cursor, and open state. GPU responses also include surface presentation fields, input latency budget fields, retained canvas frame counters, and retained widget counters. `kind: "webview"` routes through the WebView backend. The macOS, Linux, and Windows system-WebView backends support native `toolbar`, `titlebar_accessory`, `sidebar`, `statusbar`, `split`, `stack`, `button`, `icon_button`, `list_item`, `checkbox`, `toggle`, `segmented_control`, `text_field`, `search_field`, `label`, `spacer`, and `progress_indicator` views. The macOS system-WebView backend also supports `gpu_surface`; Chromium hosts and unsupported native kinds return explicit unsupported-backend errors until implemented.
## WebView Commands
<table>
<thead>
<tr>
<th>Command</th>
<th>Required permission</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>native-sdk.webview.create</code></td>
<td><code>window</code></td>
<td>Create a named WebView in a window</td>
</tr>
<tr>
<td><code>native-sdk.webview.list</code></td>
<td><code>window</code></td>
<td>List WebViews in the calling window</td>
</tr>
<tr>
<td><code>native-sdk.webview.setFrame</code></td>
<td><code>window</code></td>
<td>Move or resize a WebView</td>
</tr>
<tr>
<td><code>native-sdk.webview.navigate</code></td>
<td><code>window</code></td>
<td>Navigate a WebView</td>
</tr>
<tr>
<td><code>native-sdk.webview.setZoom</code></td>
<td><code>window</code></td>
<td>Set page zoom from <code>0.25</code> to <code>5.0</code></td>
</tr>
<tr>
<td><code>native-sdk.webview.setLayer</code></td>
<td><code>window</code></td>
<td>Change native stack order</td>
</tr>
<tr>
<td><code>native-sdk.webview.close</code></td>
<td><code>window</code></td>
<td>Close a WebView</td>
</tr>
</tbody>
</table>
WebView commands are available through `window.zero.webviews.*` when `js_window_api` is `true`. They use the same origin and `window` permission checks as window commands. WebView URLs are also checked against `security.navigation.allowed_origins`. Backend-specific gaps reject with `invalid_request` and an actionable unsupported-backend message.
If `windowId` is omitted, the runtime uses the window that sent the bridge message. If provided, `windowId` must match that same calling window. Child WebViews are isolated by default and receive `window.zero` only when created with `bridge: true`. The startup WebView is always listed as `main`; that label is reserved and cannot be used for child WebView creation.
## Dialog commands
<table>
<thead>
<tr>
<th>Command</th>
<th>Required permission</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>native-sdk.dialog.openFile</code></td>
<td><code>dialog</code></td>
<td>Show a file open dialog</td>
</tr>
<tr>
<td><code>native-sdk.dialog.saveFile</code></td>
<td><code>dialog</code></td>
<td>Show a file save dialog</td>
</tr>
<tr>
<td><code>native-sdk.dialog.showMessage</code></td>
<td><code>dialog</code></td>
<td>Show a message dialog</td>
</tr>
</tbody>
</table>
Dialog commands are **always default-deny** and require an explicit `builtin_bridge` policy.
## OS Commands
<table>
<thead>
<tr>
<th>Command</th>
<th>Required permission</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>native-sdk.os.openUrl</code></td>
<td><code>network</code></td>
<td>Open an allowed <code>http://</code> or <code>https://</code> URL in the system browser</td>
</tr>
<tr>
<td><code>native-sdk.os.showNotification</code></td>
<td><code>notifications</code></td>
<td>Show a native system notification with a title, optional subtitle, and optional body</td>
</tr>
<tr>
<td><code>native-sdk.os.revealPath</code></td>
<td><code>filesystem</code></td>
<td>Reveal a local file or folder path in the platform file manager</td>
</tr>
<tr>
<td><code>native-sdk.os.addRecentDocument</code></td>
<td><code>filesystem</code></td>
<td>Add a local path to the platform recent documents list</td>
</tr>
<tr>
<td><code>native-sdk.os.clearRecentDocuments</code></td>
<td><code>filesystem</code></td>
<td>Clear recent documents registered by the application where the platform supports it</td>
</tr>
</tbody>
</table>
OS commands are **always default-deny** and require an explicit `builtin_bridge` policy. `native-sdk.os.openUrl` also checks `security.navigation.external_links`; the URL must match the external link allowlist before the platform service is called. macOS, Linux, and Windows system WebView hosts implement `openUrl`, `revealPath`, notifications, and recent-document commands; macOS Chromium also implements the current OS command set. Other platform hosts return `invalid_request` with the standard unsupported-service message until implemented.
## Credential Commands
<table>
<thead>
<tr>
<th>Command</th>
<th>Required permission</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>native-sdk.credentials.set</code></td>
<td><code>credentials</code></td>
<td>Store a secret by <code>service</code> and <code>account</code></td>
</tr>
<tr>
<td><code>native-sdk.credentials.get</code></td>
<td><code>credentials</code></td>
<td>Read a secret by <code>service</code> and <code>account</code>, returning <code>null</code> when it is missing</td>
</tr>
<tr>
<td><code>native-sdk.credentials.delete</code></td>
<td><code>credentials</code></td>
<td>Delete a stored secret by <code>service</code> and <code>account</code></td>
</tr>
</tbody>
</table>
Credential commands are **always default-deny** and require an explicit `builtin_bridge` policy. The macOS system WebView and macOS Chromium hosts store secrets in Keychain. The Linux system WebView host stores secrets through Secret Service/libsecret when available. The Windows system WebView host stores secrets in Credential Manager. Other platform hosts return `invalid_request` with the standard unsupported-service message until implemented.
## Clipboard Commands
<table>
<thead>
<tr>
<th>Command</th>
<th>Required permission</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>native-sdk.clipboard.readText</code></td>
<td><code>clipboard</code></td>
<td>Read <code>text/plain</code> from the system clipboard</td>
</tr>
<tr>
<td><code>native-sdk.clipboard.writeText</code></td>
<td><code>clipboard</code></td>
<td>Write <code>text/plain</code> to the system clipboard</td>
</tr>
<tr>
<td><code>native-sdk.clipboard.read</code></td>
<td><code>clipboard</code></td>
<td>Read clipboard data by MIME type and return <code>{'{ mimeType, data }'}</code></td>
</tr>
<tr>
<td><code>native-sdk.clipboard.write</code></td>
<td><code>clipboard</code></td>
<td>Write clipboard data by MIME type</td>
</tr>
</tbody>
</table>
Clipboard commands are **always default-deny** and require an explicit `builtin_bridge` policy. Supported clipboard hosts provide <code>text/plain</code> through the existing text clipboard path. macOS system WebView, macOS Chromium, Linux system WebView, and Windows system WebView also support <code>text/html</code>, <code>text/rtf</code>, and <code>application/rtf</code>.
## Enabling builtin commands
```zig
const app_permissions = [_][]const u8{
native_sdk.security.permission_command,
native_sdk.security.permission_view,
native_sdk.security.permission_dialog,
native_sdk.security.permission_window,
native_sdk.security.permission_network,
native_sdk.security.permission_filesystem,
native_sdk.security.permission_clipboard,
native_sdk.security.permission_notifications,
native_sdk.security.permission_credentials,
};
.security = .{
.permissions = &app_permissions,
.navigation = .{
.allowed_origins = &.{ "zero://app" },
.external_links = .{
.action = .open_system_browser,
.allowed_urls = &.{ "https://example.com/docs/*" },
},
},
},
.builtin_bridge = .{
.enabled = true,
.commands = &.{
.{ .name = "native-sdk.window.list", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.command.invoke", .permissions = .{ "command" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.command.list", .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.view.create", .permissions = .{ "view" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.view.list", .permissions = .{ "view" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.view.update", .permissions = .{ "view" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.view.setFrame", .permissions = .{ "view" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.view.setVisible", .permissions = .{ "view" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.view.focus", .permissions = .{ "view" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.view.focusNext", .permissions = .{ "view" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.view.focusPrevious", .permissions = .{ "view" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.view.close", .permissions = .{ "view" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.webview.create", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.webview.list", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.webview.setFrame", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.webview.navigate", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.webview.setZoom", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.webview.setLayer", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.webview.close", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.dialog.openFile", .permissions = .{ "dialog" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.dialog.saveFile", .permissions = .{ "dialog" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.dialog.showMessage", .permissions = .{ "dialog" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.openUrl", .permissions = .{ "network" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.showNotification", .permissions = .{ "notifications" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.revealPath", .permissions = .{ "filesystem" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.addRecentDocument", .permissions = .{ "filesystem" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.clearRecentDocuments", .permissions = .{ "filesystem" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.clipboard.readText", .permissions = .{ "clipboard" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.clipboard.writeText", .permissions = .{ "clipboard" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.clipboard.read", .permissions = .{ "clipboard" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.clipboard.write", .permissions = .{ "clipboard" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.credentials.set", .permissions = .{ "credentials" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.credentials.get", .permissions = .{ "credentials" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.credentials.delete", .permissions = .{ "credentials" }, .origins = .{ "zero://app" } },
},
},
```
## JavaScript usage
```javascript
await window.zero.windows.create({
label: "tools",
title: "Tools",
width: 420,
height: 320,
});
const preview = await window.zero.webviews.create({
label: "preview",
url: "https://example.com",
frame: { x: 24, y: 24, width: 480, height: 320 },
layer: 10,
bridge: false,
});
await preview.setZoom(1.25);
await preview.setLayer(20);
await preview.close();
const toolbar = await window.zero.views.create({
label: "toolbar",
kind: "toolbar",
frame: { x: 0, y: 0, width: 720, height: 44 },
role: "toolbar",
});
await window.zero.views.create({
label: "refresh",
kind: "button",
parent: "toolbar",
frame: { x: 12, y: 8, width: 96, height: 28 },
accessibilityLabel: "Refresh workspace",
text: "Refresh",
command: "app.refresh",
});
await toolbar.setVisible(false);
await window.zero.commands.invoke("app.save");
const commands = await window.zero.commands.list();
const hasNativeViews = await window.zero.platform.supports("native_views");
const files = await window.zero.invoke("native-sdk.dialog.openFile", {
title: "Select a file",
allowMultiple: true,
});
const result = await window.zero.invoke("native-sdk.dialog.showMessage", {
style: "warning",
title: "Confirm",
message: "Are you sure?",
primaryButton: "Yes",
secondaryButton: "No",
});
await window.zero.os.openUrl("https://example.com/docs/start");
await window.zero.os.showNotification({
title: "Build finished",
subtitle: "native-sdk",
body: "All checks passed.",
});
await window.zero.os.revealPath("/Users/me/Downloads/report.pdf");
await window.zero.os.addRecentDocument("/Users/me/Downloads/report.pdf");
await window.zero.os.clearRecentDocuments();
await window.zero.clipboard.writeText("Copied from Native SDK");
const text = await window.zero.clipboard.readText();
await window.zero.clipboard.write({
mimeType: "text/html",
data: "<strong>Copied from Native SDK</strong>",
});
const html = await window.zero.clipboard.read({ mimeType: "text/html" });
await window.zero.credentials.set({
service: "com.example.app",
account: "current-user",
secret: "session-token",
});
const token = await window.zero.credentials.get({
service: "com.example.app",
account: "current-user",
});
await window.zero.credentials.delete({
service: "com.example.app",
account: "current-user",
});
```
See also: [Multiple WebViews](/webviews) for frame and layer semantics, [Dialogs](/dialogs) for the full dialog type reference, [Capabilities](/capabilities) for OS capability support, and [Security](/security) for policy details.
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("bridge");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+178
View File
@@ -0,0 +1,178 @@
# Bridge
For apps that [embed web content](/frontend), the bridge connects JavaScript in the WebView to native Zig handlers via JSON messages. Native-rendered apps have no bridge — markup dispatches typed messages straight into `update` (see [App Model](/app-model)).
## Architecture
```
WebView JS Zig Runtime
────────── ───────────
window.zero.invoke(cmd, payload)
│ │
├──── JSON message ───────────►│
│ Size check (16 KiB max)
│ Policy check (origin + permissions)
│ Handler lookup + execute
│◄─── JSON response ──────────┤
```
## Defining a handler
```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});
}
```
The handler writes its JSON result into the provided `output` buffer (max 12 KiB) and returns a slice of it. Results must be valid JSON values; invalid raw text is rejected with `handler_failed`. When returning user data as a string, use the bridge helper so quotes and control characters are escaped:
```zig
return native_sdk.bridge.writeJsonStringValue(output, user_supplied_name);
```
## Wiring the dispatcher
```zig
fn bridge(self: *App) native_sdk.BridgeDispatcher {
self.handlers = .{.{ .name = "native.ping", .context = self, .invoke_fn = ping }};
return .{
.policy = .{ .enabled = true, .commands = &policies },
.registry = .{ .handlers = &self.handlers },
};
}
```
## Calling from JavaScript
```javascript
const result = await window.zero.invoke("native.ping", { source: "webview" });
console.log(result); // { message: "pong from Zig", count: 1 }
```
## Invocation
When a handler is called, it receives an `Invocation` with:
- `request.id` -- caller-provided request ID (max 64 bytes)
- `request.command` -- command name (max 128 bytes, no `/` or spaces)
- `request.payload` -- JSON payload string
- `source.origin` -- origin of the requesting page (e.g. `zero://app`)
- `source.window_id` -- which window sent the request
## Size limits
<table>
<thead>
<tr>
<th>Constant</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>max_message_bytes</code></td>
<td>16 KiB</td>
</tr>
<tr>
<td><code>max_response_bytes</code></td>
<td>16 KiB</td>
</tr>
<tr>
<td><code>max_result_bytes</code></td>
<td>12 KiB</td>
</tr>
<tr>
<td><code>max_id_bytes</code></td>
<td>64</td>
</tr>
<tr>
<td><code>max_command_bytes</code></td>
<td>128</td>
</tr>
</tbody>
</table>
## Error codes
When a bridge call fails, the JS promise rejects with an error containing a `code` field:
<table>
<thead>
<tr>
<th>Code</th>
<th>Cause</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>invalid_request</code></td>
<td>Malformed JSON message</td>
</tr>
<tr>
<td><code>unknown_command</code></td>
<td>No handler registered</td>
</tr>
<tr>
<td><code>permission_denied</code></td>
<td>Origin or permission check failed</td>
</tr>
<tr>
<td><code>handler_failed</code></td>
<td>Handler returned an error</td>
</tr>
<tr>
<td><code>payload_too_large</code></td>
<td>Message exceeds 16 KiB</td>
</tr>
<tr>
<td><code>internal_error</code></td>
<td>Unexpected runtime error</td>
</tr>
</tbody>
</table>
```javascript
try {
const result = await window.zero.invoke("native.ping", {});
} catch (error) {
console.error(error.code, error.message);
}
```
## Bridge types
<table>
<thead>
<tr>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>BridgeDispatcher</code></td>
<td>Combines policy and registry</td>
</tr>
<tr>
<td><code>BridgePolicy</code></td>
<td>Whether the bridge is enabled and which commands are allowed</td>
</tr>
<tr>
<td><code>BridgeCommandPolicy</code></td>
<td>Per-command: <code>name</code>, <code>permissions</code>, <code>origins</code></td>
</tr>
<tr>
<td><code>BridgeRegistry</code></td>
<td>Maps command names to handler functions</td>
</tr>
<tr>
<td><code>BridgeHandler</code></td>
<td><code>name</code>, <code>context</code>, <code>invoke_fn</code></td>
</tr>
</tbody>
</table>
See also: [Builtin Commands](/bridge/builtin-commands) for `native-sdk.command.*`, `native-sdk.window.*`, `native-sdk.view.*`, `native-sdk.webview.*`, `native-sdk.dialog.*`, `native-sdk.os.*`, `native-sdk.clipboard.*`, and `native-sdk.credentials.*`.
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("building-components");
export default function BuildingComponentsLayout({ children }: { children: React.ReactNode }) {
return children;
}
+166
View File
@@ -0,0 +1,166 @@
# Building Components
The library's built-ins cover the common register, and [theming](/theming) restyles all of them at once. This page is about the pieces the library does not hand you: how to build a component of your own — first as a markup template, then as a Zig view function when the shape needs one — how it themes, and how component files spread across an app. Component code is toolkit-extension territory, so the Zig here applies whatever language the app core is written in: a TypeScript app that needs one custom widget writes that widget in Zig and keeps its core in TypeScript. The mechanics (template grammar, import rules, slots) are specified in [Native UI](/native-ui#templates); this page builds one real component end to end.
The ownership model in one line: **use and theme the built-ins by default; eject a library composite when you need to own its shape; build new composites from primitives when the library has no shape for it.** The last two are this page.
## A component in markup
A dashboard needs the same labeled stat tile three times. That repetition is a `<template>` — define the subtree once at the top of the view file, give its varying parts names in `args`, and stamp it with `<use>`:
```html
<template name="stat-card" args="label value">
<column background="surface" border-color="border" radius="lg" padding="12" gap="4" role="group" label="{label}">
<text size="sm" foreground="text_muted">{label}</text>
<text size="heading">{value}</text>
</column>
</template>
<column gap="12" padding="16">
<row gap="12">
<use template="stat-card" label="CPU" value="{cpuPercent}" />
<use template="stat-card" label="Memory" value="{memoryUsed}" />
</row>
</column>
```
Inside the body, args bind exactly like model bindings: `{label}` in text, `label="{label}"` in attributes. At the use site an arg's value may be a literal (`label="CPU"`), a binding (`value="{cpuPercent}"`), or an expression — and the attributes must match the declared args exactly, so a typo is a check error, never a silently ignored prop. The body is built in place of the `<use>`: widget ids hash as if you had written it out by hand, which means converting copy-pasted markup into a template changes no identity, no retained state, no automation target.
Optional args declare a literal default with `name=value` (`args="label value hint="` gives `hint` an empty-string default), and the body can branch on them — this is how the card grows an optional footnote without a second template:
```html
<template name="stat-card" args="label value hint=">
<column background="surface" border-color="border" radius="lg" padding="12" gap="4" role="group" label="{label}">
<text size="sm" foreground="text_muted">{label}</text>
<text size="heading">{value}</text>
<if test="{hint}">
<text size="sm" foreground="text_muted">{hint}</text>
</if>
</column>
</template>
```
## Moving it to a component file
Once a second view wants the card, move the template into its own file. A file of templates and nothing else is a **component file** — it has no view root, and that is what makes it importable:
```html
<!-- src/components/stat-card.native -->
<template name="stat-card" args="label value hint=">
<column background="surface" border-color="border" radius="lg" padding="12" gap="4" role="group" label="{label}">
<text size="sm" foreground="text_muted">{label}</text>
<text size="heading">{value}</text>
<if test="{hint}">
<text size="sm" foreground="text_muted">{hint}</text>
</if>
</column>
</template>
```
The view imports it at the very top, before anything else, and uses it exactly as before:
```html
<import src="components/stat-card.native"/>
<column gap="12" padding="16" background="background">
<row gap="12">
<use template="stat-card" label="CPU" value="{cpuPercent}" hint="last minute" />
<use template="stat-card" label="Memory" value="{memoryUsed}" />
<use template="stat-card" label="Uptime" value="{uptimeText}" />
</row>
</column>
```
Validate as you go: `native markup check src/app.native` follows the import closure from disk, and a component file also checks standalone (`native markup check src/components/stat-card.native`). `native check` walks everything under `src/` in one pass.
## Passing children with a slot
Args carry values; a `<slot/>` carries markup. A template body may mark one insertion point, and the use site's children build there — in the consumer's scope, so they see the model paths and loop variables where the `<use>` is written:
```html
<template name="empty-state" args="title">
<column main="center" cross="center" gap="8" padding="24" role="group" label="{title}">
<text foreground="text_muted">{title}</text>
<slot/>
</column>
</template>
<column grow="1">
<use template="empty-state" title="No results yet">
<button variant="primary" on-press="refresh">Refresh</button>
</use>
</column>
```
This is the container-component pattern: the template owns the frame, the caller owns the content. The full rules (one slot per body, children without a slot are an error, ids hash as if inlined) are in [Native UI § Components](/native-ui#components).
## When a component needs Zig
The markup grammar is deliberately closed, and a few shapes sit outside it — the honest list is in [Native UI § Elements](/native-ui#elements): components that carry **image ids** (pixels registered at runtime; the avatar's `image="{binding}"` is the one declarative exception), **per-cell templates** (a data grid's arbitrary render-per-column callbacks), and **Zig-side floating surfaces** (`popover`, `menu_surface`; the anchored `dropdown-menu` covers the declarative case). Beyond those, anything needing per-state styling past tokens (`ElementOptions.style`) or logic past the expression language belongs in Zig.
A Zig component is just a function that takes the view builder and returns a node — the same primitives markup lowers to, with the same structural identity rules:
```zig
const canvas = native_sdk.canvas;
const Ui = canvas.Ui(Msg);
/// A contact row with an avatar image. Image pixels are registered at
/// runtime and referenced by ImageId — a runtime value markup attributes
/// cannot carry — so this component is a Zig view function.
fn contactRow(ui: *Ui, name: []const u8, initials: []const u8, image: canvas.ImageId) Ui.Node {
return ui.el(.row, .{
.gap = 10,
.padding = 8,
.cross = .center,
.style_tokens = .{ .background = .surface, .radius = .md },
.semantics = .{ .role = .listitem, .label = name },
}, .{
// A zero id keeps the initials fallback — write the id into the
// model only on successful registration, and loading states cost
// no extra branch here.
ui.avatar(.{ .image = image, .size = .sm }, initials),
ui.text(.{ .grow = 1 }, name),
});
}
```
Call it from any Zig view (`contactRow(ui, contact.name, contact.initials, contact.avatar_image)`), key it inside `ui.each` loops like any node, and compose it with everything else the builder makes. Markup views and Zig views are not either/or per app — a markup root can be paired with Zig-built windows, and a Zig root can embed compiled markup fragments — but one component is one form: pick markup when the grammar covers it (hot reload and `native check` come free), Zig when it does not.
## Theming your component
Your component themes the same way the built-ins do: through the token system, never through hardcoded values.
In markup, the style attributes (`background`, `foreground`, `border-color`, `radius`, ...) are token **references** — the stat card above says `background="surface"`, not a hex value. References resolve against the app's live tokens on every rebuild, so the card follows dark mode, a theme-pack switch, and every override with zero component code. Unknown token names are check/compile errors.
In a Zig view, `ElementOptions.style_tokens` is the same channel — `.style_tokens = .{ .background = .surface, .radius = .md }` records the reference, and the app loop resolves it against the current `DesignTokens` when the tree finalizes (`finalizeWithTokens`), re-resolving on every retheme. Explicit values through `ElementOptions.style` always win over a token reference; use them only for the values that are genuinely not design tokens (a user-picked highlight color, a data-derived fill).
State washes follow one rule: hover feedback belongs to acting controls. List rows, menu items, buttons, and tab triggers wash on hover because the fill is the affordance — it names the thing you are about to act on. An image-forward content tile is the opposite case — a cover-art grid, a photo card — where the pointer rests on content, not a control register, and a wash over the artwork reads as a smudge. Those surfaces go quiet with the quiet-surface knob, `.style = .{ .quiet_hover = true }`, which silences only the hover fill: the pressed wash still marks the moment of commitment, and the focus ring, cursor intent, and hit testing keep their own channels. Like everything per-state beyond tokens, it is a Zig-side style decision; markup stays in the token vocabulary.
And the parts of your component that are built-in controls stay themed for free: the `<button>` inside the empty-state template wears `controls.button_primary` from the active theme, the `text` leaves read the typography rungs, the focus ring follows the token geometry. A template or view function composes primitives, so it inherits the whole register through them — retheme the app and your components move with it. That inheritance is the reason to reach for tokens before props: a component that takes a `color` arg has opted out of every future theme.
## Distributing components across an app
Component files live wherever you like under the markup root (the root view file's directory) — `src/components/` is the convention the tooling writes to, and subdirectories work (`<import src="components/cards/stat-card.native"/>`). Paths are relative to the importing file, imports are transitive, and everything must stay under the markup root. Cycles are reported with the chain spelled out, and the same template name defined in two files is an error naming both sites — imports never shadow silently.
Two build details to know as an app grows:
- The runtime interpreter re-resolves imports from disk on hot reload, so `native dev` picks up edits to component files, not just the root view.
- The compiled engine and the runtime share one embedded source set — add each component file to it once:
```zig
pub const app_markup_files = [_]canvas.ui_markup.SourceFile{
.{ .path = "app.native", .source = @embedFile("app.native") },
.{ .path = "components/stat-card.native", .source = @embedFile("components/stat-card.native") },
};
// runtime: .markup = .{ .source = app_markup, .sources = &app_markup_files, ... }
// tests: canvas.ui_markup.resolveImports(arena, "app.native", app_markup, loader, &diagnostic)
```
Zig components distribute the ordinary Zig way: a file per component (or a `components/` module), imported with `@import`.
## Use, eject, or build
Three moves, in order of preference:
**Theme it.** Every built-in reads the token register, and [theme packs, overrides, and full design systems](/theming) reach every visual decision — colors, radii, control metrics, state washes, type. If your need is "the stepper, but in our palette," that is a token change, not a component.
**Eject it.** When you need to own a composite's *shape* — reorder its parts, change its structure, grow it a feature — `native eject component <name>` writes the library composite's canonical source into `src/components/`, and from then on it is your code: edit freely, SDK updates never touch it. The ejectable set is exactly the library views that are honest compositions of primitives — today `stepper` and `timeline-item` (Zig view functions; their conditional structure and formatted text sit outside the markup grammar) and `timeline` (a markup template, since the container is pure composition). Each ejected file builds a widget tree identical to its library form at the moment of ejection — held by tests in the SDK — so ejecting changes ownership, never pixels. Ejected templates are reached through `<use template="timeline" ...>`, never a new element name, so the built-in element keeps working at unmigrated call sites; ejecting twice errors instead of overwriting your edits. Engine controls (buttons, text fields, tabs, ...) are deliberately not on the menu: their behavior lives in the runtime, and the way to change them is the token system, not a fork.
**Build it.** When the library has no shape for it, compose one from primitives — the stat card and empty-state above, or a Zig view function when the shape needs Zig. Your component then themes, tests, and automates exactly like a built-in, because it is made of the same parts.
@@ -0,0 +1,7 @@
# Built-in Components
This page moved. The catalog now has a full [Components section](/components) — one page per component with engine-rendered light/dark previews, validated Native markup, the Zig builder equivalent, and attribute tables generated from the markup vocabulary.
The short version: the Native SDK ships a native-rendered component catalog with house-style defaults — neutral surfaces, Geist typography, subtle borders, focus states, and token-driven color, radius, shadow, blur, and motion — owned by the SDK and rendered through the retained canvas surface, not platform widget skins. Every component is expressible in Native markup through its element and programmatically through the `canvas.Ui` builder.
The canonical catalog lives in `native_sdk.canvas.builtin_component_kinds` and `builtin_component_names`; each descriptor reports its style, root widget kind, semantic role, and composite flag, and `native_sdk.canvas.builtinComponentWidget(...)` constructs a component's default foundation directly. See [Native UI](/native-ui) for markup semantics and the runtime contract.
+223
View File
@@ -0,0 +1,223 @@
# Capabilities
Native SDK capabilities are native OS services and app events exposed through `PlatformServices`, runtime methods, lifecycle events, and — for apps that [embed web content](/frontend) — guarded bridge commands. Native code reaches them directly; web content does not receive capability access by default. In a [`UiApp`](/app-model), clipboard access rides the effects channel (`fx.writeClipboard` / `fx.readClipboard`) so `update` never needs a runtime handle.
Web content itself is declare-to-use: an app ships the embedded web layer only when it declares web intent — `"webview"` in `.capabilities`, a `.frontend` block, a `.shell` webview view, or a web engine resolved to Chromium (`.web_engine = "chromium"` in app.zon, or the `-Dweb-engine`/`--web-engine` flags) — and an app that declares none of them builds native-only, where any attempt to create a webview fails with a teaching error instead of loading a layer the app never asked for. The [`webview_layer`](/app-zon) manifest field overrides the inference in either direction. Native-only builds shed the platform web stack for real: the Windows executable carries no `WebView2Loader.dll` reference, and the Linux host neither links WebKitGTK nor requires `libwebkitgtk` on user machines.
## Current capability pack
<table>
<thead>
<tr>
<th>Capability</th>
<th>Zig API</th>
<th>JavaScript command</th>
<th>Bridge permission</th>
<th>Current native support</th>
</tr>
</thead>
<tbody>
<tr>
<td>Open URL in system browser</td>
<td><code>runtime.openExternalUrl(url)</code></td>
<td><code>native-sdk.os.openUrl</code></td>
<td><code>network</code></td>
<td>macOS, Linux, and Windows system WebView; macOS Chromium</td>
</tr>
<tr>
<td>System notification</td>
<td><code>runtime.showNotification(options)</code></td>
<td><code>native-sdk.os.showNotification</code></td>
<td><code>notifications</code></td>
<td>macOS, Linux, and Windows system WebView; macOS Chromium</td>
</tr>
<tr>
<td>Native dialogs</td>
<td><code>runtime.showOpenDialog(...)</code> / <code>showSaveDialog(...)</code> / <code>showMessageDialog(...)</code></td>
<td><code>native-sdk.dialog.openFile</code> / <code>saveFile</code> / <code>showMessage</code></td>
<td><code>dialog</code></td>
<td>macOS system WebView and Chromium; Linux and Windows system WebView</td>
</tr>
<tr>
<td>Reveal path in file manager</td>
<td><code>runtime.revealPath(path)</code></td>
<td><code>native-sdk.os.revealPath</code></td>
<td><code>filesystem</code></td>
<td>macOS, Linux, and Windows system WebView; macOS Chromium</td>
</tr>
<tr>
<td>Recent documents</td>
<td><code>runtime.addRecentDocument(path)</code> / <code>runtime.clearRecentDocuments()</code></td>
<td><code>native-sdk.os.addRecentDocument</code> / <code>native-sdk.os.clearRecentDocuments</code></td>
<td><code>filesystem</code></td>
<td>macOS, Linux, and Windows system WebView; macOS Chromium</td>
</tr>
<tr>
<td>Clipboard</td>
<td><code>runtime.readClipboard()</code> / <code>runtime.writeClipboard(text)</code> / <code>runtime.readClipboardData(mimeType)</code> / <code>runtime.writeClipboardData(data)</code></td>
<td><code>native-sdk.clipboard.readText</code> / <code>native-sdk.clipboard.writeText</code> / <code>native-sdk.clipboard.read</code> / <code>native-sdk.clipboard.write</code></td>
<td><code>clipboard</code></td>
<td><code>text/plain</code> on supported clipboard hosts; <code>text/html</code> and RTF on macOS system WebView, macOS Chromium, Linux system WebView, and Windows system WebView</td>
</tr>
<tr>
<td>Audio playback</td>
<td><code>fx.playAudio(options)</code> / <code>fx.pauseAudio()</code> / <code>fx.resumeAudio()</code> / <code>fx.stopAudio()</code> / <code>fx.seekAudio(ms)</code> / <code>fx.setAudioVolume(v)</code></td>
<td>None. Effects-channel API; every report arrives as an <code>on_event</code> Msg.</td>
<td>None. No bridge surface.</td>
<td>macOS system-engine host (ONE AVPlayer for local files, verified cache entries, and streamed URLs — one tappable path), Windows system WebView (one Media Foundation media session for local files and streamed URLs), Linux system WebView (one GStreamer playbin for local files and streamed URLs, loaded at runtime — a host without GStreamer reports unsupported), the iOS toolkit host (the AVFoundation player behind the embed audio service, with an honest playback audio session — interruptions pause and report), and the Android toolkit host (one MediaPlayer for local files, verified cache entries, and progressive URL streams behind the same embed audio service, with honest audio focus — a focus loss pauses and reports); macOS Chromium and GStreamer-less Linux hosts report unsupported and playback degrades to one explicit failed Msg</td>
</tr>
<tr>
<td>Audio spectrum analysis</td>
<td>None to call — hosts that analyze deliver <code>.spectrum</code> audio events through the same <code>on_event</code> Msg as position ticks</td>
<td>None. Effects-channel events.</td>
<td>None. No bridge surface.</td>
<td>macOS system-engine host (an MTAudioProcessingTap on the app's single AVPlayer feeding a vDSP FFT — local files, cache entries, and streams alike), Windows system WebView (process-scoped WASAPI loopback capture of THIS app's audio session only — never other apps' audio — with an in-box FFT), and Linux system WebView (GStreamer's <code>spectrum</code> element as the playbin's audio-filter; requires gst-plugins-good, probed live); hosts that cannot analyze — macOS Chromium, pre-2004 Windows, spectrum-less GStreamer setups, iOS, Android — report <code>audio_spectrum</code> unsupported and simply never send the events: honest absence, never fabricated bands</td>
</tr>
<tr>
<td>File drops</td>
<td><code>Event.files_dropped</code></td>
<td><code>window.zero.on("drop:files")</code></td>
<td>None. Host-emitted event.</td>
<td>macOS, Linux, and Windows system WebView</td>
</tr>
<tr>
<td>Credential store</td>
<td><code>runtime.setCredential(options)</code> / <code>runtime.getCredential(key)</code> / <code>runtime.deleteCredential(key)</code></td>
<td><code>native-sdk.credentials.set</code> / <code>native-sdk.credentials.get</code> / <code>native-sdk.credentials.delete</code></td>
<td><code>credentials</code></td>
<td>macOS system WebView and macOS Chromium through Keychain; Linux system WebView through Secret Service/libsecret when available; Windows system WebView through Credential Manager</td>
</tr>
<tr>
<td>App activation events</td>
<td><code>LifecycleEvent.activate</code> / <code>LifecycleEvent.deactivate</code></td>
<td><code>window.zero.on("app:activate")</code> / <code>window.zero.on("app:deactivate")</code></td>
<td>None. Host-emitted event.</td>
<td>macOS, Linux, and Windows system WebView; macOS Chromium</td>
</tr>
<tr>
<td>File associations and URL schemes</td>
<td><code>Manifest.file_associations</code> / <code>Manifest.url_schemes</code></td>
<td>None. Packaging metadata.</td>
<td>None. Manifest-only declaration.</td>
<td>Parsed, validated, and emitted in macOS, Linux, and Windows package registration metadata.</td>
</tr>
</tbody>
</table>
Unsupported platform hosts reject with the standard unsupported-service error.
## Audio streaming and the track cache
`fx.playAudio` resolves its source in a fixed order: the local `path` first; when it is absent or missing, the `url` — where a verified cache entry at `cache_path` plays as a plain local file (no network), and anything else streams progressively (playback starts while bytes are still arriving, never download-then-play) while the same bytes fill the cache for the next play. `expected_bytes` (the track's known size, from a manifest) is the integrity gate: a cache entry or a finished download whose size disagrees never plays — it is discarded and re-streamed. While a stream waits for bytes, position events carry `buffering = true`: an honest third UI state, distinct from playing and paused, since the transport is not paused but nothing is coming out of the speakers. Seek and volume work mid-stream; a network failure mid-stream (or offline with a cold cache) delivers one explicit `.failed` Msg.
The cache lives under the platform caches directory (the `app_dirs` `.cache` convention — `~/Library/Caches/<app>/audio/` on macOS, `$XDG_CACHE_HOME/<app>/audio/` on Linux, `%LOCALAPPDATA%\<app>\Cache\audio\` on Windows, the app sandbox container's `Library/Caches/audio/` on iOS and the app data directory's `cache/audio/` on Android — both namespaces are already per-app, and both systems treat those directories as reclaimable), keyed by the URL's hash (`native_sdk.audioCachePath` derives the path). Clearing the cache is deleting that one directory; the OS already treats caches there as reclaimable. Hosts with a player but no streaming path report `audio_streaming` unsupported and URL playback degrades to the same explicit failed Msg. For record/replay, streaming changes nothing: the delivered audio events are journaled at the effect boundary like every other result, so network timing — real-world nondeterminism — stays outside the recording and replay never touches the network.
## Spectrum analysis
Hosts with `audio_spectrum` analyze the audio the app itself is producing and deliver `.spectrum` events through the active playback's ordinary `on_event` Msg: 32 band magnitudes with log-spaced center frequencies covering roughly 50 Hz16 kHz, each band a byte mapping linearly in decibels from the 60 dBFS analysis floor (0) to full scale (255) — divide by 255 for a 0..1 level, and equal visual steps read as equal dB steps, the way hardware analyzers do. Per band the host reports the peak FFT-bin magnitude inside the bucket. Events arrive at a steady ~25 Hz (40 ms cadence) only while audio is audibly playing: pause and stop starve the stream, and a buffering stall goes quiet with the speakers, so everything a UI derives from the bands freezes honestly. Silence while playing is a row of floor bytes, still delivered — the cadence follows the transport, the magnitudes tell the truth. The bands also describe a display, so a host that knows the app has no visible window stops delivering them while the glass is away — macOS while every window is occluded (minimized, fully covered, hidden), Windows while every window is minimized — and resumes within one report of reveal; playback and the position ticks continue untouched, and an occluded stretch journals as honest silence. Linux keeps emitting regardless: no visibility signal holds across compositors, and a gate that might never clear would freeze visible bars.
The mechanisms are host-native and in-box: macOS taps the app's single AVPlayer with an MTAudioProcessingTap and folds a vDSP FFT into the bands (the tap sits pre-effects, so the bands describe the decoded track, not the app's volume fader); Windows captures the app's OWN audio session through process-scoped WASAPI loopback — never system-wide, so other apps' audio can never pollute the bands — and runs an in-box FFT; Linux installs GStreamer's `spectrum` element as the playbin's audio-filter and folds its linear bands into the documented log buckets. A host that cannot analyze (macOS Chromium, Windows before 10 2004, a GStreamer install without gst-plugins-good, iOS, Android) reports `audio_spectrum` unsupported and never sends the events — apps should show a resting state, not fake motion. For record/replay, the bands are honest non-determinism recorded at the edge: every `.spectrum` event journals verbatim at the effect boundary like a position tick, so replay repaints identical bars, and the null platform ships a deterministic fake generator so tests stay hermetic.
## Support discovery
Use `runtime.supports(...)` before native code shows optional affordances. Trusted WebView code can make the same check with `native-sdk.platform.supports` when that built-in command is explicitly allowed:
```javascript
const support = {
notifications: await window.zero.platform.supports("notifications"),
dialogs: await window.zero.platform.supports("dialogs"),
clipboardRichData: await window.zero.platform.supports("clipboard_rich_data"),
credentials: await window.zero.platform.supports("credentials"),
fileDrops: await window.zero.platform.supports("file_drops"),
audioPlayback: await window.zero.platform.supports("audio_playback"),
audioStreaming: await window.zero.platform.supports("audio_streaming"),
audioSpectrum: await window.zero.platform.supports("audio_spectrum"),
};
```
## Security
OS bridge commands are default-deny. Add explicit `builtin_bridge` entries for each command your app calls:
```zig
.builtin_bridge = .{
.enabled = true,
.commands = &.{
.{ .name = "native-sdk.platform.supports", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.openUrl", .permissions = .{ "network" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.showNotification", .permissions = .{ "notifications" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.dialog.openFile", .permissions = .{ "dialog" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.dialog.saveFile", .permissions = .{ "dialog" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.dialog.showMessage", .permissions = .{ "dialog" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.revealPath", .permissions = .{ "filesystem" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.addRecentDocument", .permissions = .{ "filesystem" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.clearRecentDocuments", .permissions = .{ "filesystem" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.clipboard.readText", .permissions = .{ "clipboard" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.clipboard.writeText", .permissions = .{ "clipboard" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.clipboard.read", .permissions = .{ "clipboard" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.clipboard.write", .permissions = .{ "clipboard" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.credentials.set", .permissions = .{ "credentials" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.credentials.get", .permissions = .{ "credentials" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.credentials.delete", .permissions = .{ "credentials" }, .origins = .{ "zero://app" } },
},
},
```
Opening external URLs is also gated by the external-link policy:
```zig
.security = .{
.navigation = .{
.external_links = .{
.action = .open_system_browser,
.allowed_urls = &.{ "https://example.com/docs/*" },
},
},
},
```
## JavaScript
```javascript
await window.zero.os.openUrl("https://example.com/docs/start");
await window.zero.os.showNotification({
title: "Build finished",
subtitle: "native-sdk",
body: "All checks passed.",
});
await window.zero.os.revealPath("/Users/me/Downloads/report.pdf");
await window.zero.os.addRecentDocument("/Users/me/Downloads/report.pdf");
await window.zero.os.clearRecentDocuments();
await window.zero.clipboard.writeText("Copied from Native SDK");
const text = await window.zero.clipboard.readText();
await window.zero.clipboard.write({
mimeType: "text/html",
data: "<strong>Copied from Native SDK</strong>",
});
const html = await window.zero.clipboard.read({ mimeType: "text/html" });
window.zero.on("drop:files", (event) => {
console.log(event.paths);
});
await window.zero.credentials.set({
service: "com.example.app",
account: "current-user",
secret: "session-token",
});
const token = await window.zero.credentials.get({
service: "com.example.app",
account: "current-user",
});
await window.zero.credentials.delete({
service: "com.example.app",
account: "current-user",
});
window.zero.on("app:activate", () => {
refreshForegroundState();
});
```
See also: [Builtin Commands](/bridge/builtin-commands) and [Security](/security).
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("cli/dev");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+74
View File
@@ -0,0 +1,74 @@
# Dev Server
Run `native dev` in an app directory to build and launch the app. It builds Debug by default — the markup hot-reload watcher and the Debug-only teaching diagnostics are compiled in only in Debug; pass `-Doptimize=...` to override (`native build` stays ReleaseFast by default). Native-rendered apps get markup hot reload (edit `src/app.native` while the app runs); no build files are required — apps without a `build.zig` are driven through a CLI-generated build graph, apps with one through `zig build`.
For apps with a frontend dev config in `app.zon`, `native dev` also owns the frontend server lifecycle. It starts the configured frontend process, waits for the port to accept connections, launches the native shell with `NATIVE_SDK_FRONTEND_URL`, sets `NATIVE_SDK_HMR=1`, and terminates the frontend when the shell exits. Framework HMR stays owned by the dev server (Vite, Next.js, etc.) because the WebView loads the dev URL directly.
## Usage
```bash
native dev
native dev path/to/app -Dweb-engine=chromium
native dev --binary zig-out/bin/MyApp
native dev --binary zig-out/bin/MyApp --url http://127.0.0.1:3000/ --command "npm run dev"
native dev --binary zig-out/bin/MyApp --timeout-ms 60000
```
With `--binary`, the build step is skipped and only the frontend dev flow runs against the prebuilt shell (this is what the expanded template's `zig build dev` step invokes).
## Flags
<table>
<thead>
<tr>
<th>Flag</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>--manifest</code></td>
<td>Path to <code>app.zon</code> (default: <code>app.zon</code>)</td>
</tr>
<tr>
<td><code>--binary</code></td>
<td>Path to the built native binary</td>
</tr>
<tr>
<td><code>--url</code></td>
<td>Override dev server URL from <code>app.zon</code></td>
</tr>
<tr>
<td><code>--command</code></td>
<td>Override dev server command from <code>app.zon</code></td>
</tr>
<tr>
<td><code>--timeout-ms</code></td>
<td>Override readiness timeout (default from <code>app.zon</code>)</td>
</tr>
</tbody>
</table>
## Configuration in app.zon
```zig
.frontend = .{
.dist = "dist",
.entry = "index.html",
.spa_fallback = true,
.dev = .{
.url = "http://127.0.0.1:5173/",
.command = .{ "npm", "run", "dev", "--", "--host", "127.0.0.1" },
.ready_path = "/",
.timeout_ms = 30000,
},
}
```
## Framework recipes
**Vite**: `.url = "http://127.0.0.1:5173/"`, command `npm run dev -- --host 127.0.0.1`.
**Next.js**: `.url = "http://127.0.0.1:3000/"`, command `npm run dev -- --hostname 127.0.0.1`.
**Static preview**: point `.dist` at the build output and use any local server command.
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("cli");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+326
View File
@@ -0,0 +1,326 @@
# CLI
The `native` CLI provides project scaffolding, markup validation, automation, packaging, and debugging tools. Every verb accepts `--help` (prints usage and exits 0); unknown flags error with the usage text.
## Core loop
### `native init`
```sh
native init [path] [--template <ts-core|zig-core>] [--frontend <native|next|vite|react|svelte|vue>] [--full]
```
Scaffold a new Native SDK project. The default `native` frontend scaffolds a native-rendered markup app with no web frontend and no build files — the CLI owns the build. The default core is TypeScript: `src/core.ts`, `src/app.native`, `app.zon`, and no language flag anywhere else — the build detects which core the tree carries. Omit `path` to scaffold into the current directory.
<dl>
<dt><code>--template</code></dt>
<dd>Which core to scaffold: <code>ts-core</code> (default) for <code>src/core.ts</code>, or <code>zig-core</code> for <code>src/main.zig</code> plus generated full-loop tests. Applies to the native frontend only.</dd>
<dt><code>--frontend</code></dt>
<dd>What to scaffold: <code>native</code> (default) for a native-rendered markup app, or <code>next</code>, <code>vite</code>, <code>react</code>, <code>svelte</code>, <code>vue</code> for a web frontend in a WebView shell.</dd>
<dt><code>--full</code></dt>
<dd>Also write <code>build.zig</code>, <code>build.zig.zon</code>, editor config, and CI.</dd>
</dl>
### `native dev`
```sh
native dev [dir]
native dev [dir] --core [--script msgs.ndjson] [--watch]
native dev --binary <path> [--manifest app.zon] [--url <url>] [--command "<cmd>"] [--timeout-ms <n>]
```
Build and run the app in the current (or given) app directory — a Debug build by default, printing a one-line completion and naming any failing step. The markup hot-reload watcher and the Debug-only teaching diagnostics are compiled in only in Debug; pass `-Doptimize=...` to override. Apps with a frontend dev config also get the managed dev server — see [Dev Server](/cli/dev).
<dl>
<dt><code>--core</code></dt>
<dd>Run the TypeScript core's logic loop under node instead of building the app: dispatch Msgs as JSON lines on stdin, watch the committed model and effect transcript, advance a virtual clock to fire timers. Honestly not a renderer — plain <code>native dev</code> runs the real app. See <a href="/typescript#the-dev-loop">TypeScript Cores</a>.</dd>
<dt><code>--script</code></dt>
<dd>With <code>--core</code>: replay a newline-delimited JSON message file instead of reading stdin.</dd>
<dt><code>--watch</code></dt>
<dd>With <code>--core</code>: re-run the script on every <code>src/core.ts</code> edit.</dd>
<dt><code>--binary</code></dt>
<dd>Path to a prebuilt app binary — the legacy prebuilt-shell form: the build step is skipped and only the frontend dev flow runs. Ordinary <code>native dev</code> builds the app itself.</dd>
<dt><code>--manifest</code></dt>
<dd>Path to <code>app.zon</code> (default: <code>app.zon</code>).</dd>
<dt><code>--url</code></dt>
<dd>Override the dev server URL from <code>app.zon</code>.</dd>
<dt><code>--command</code></dt>
<dd>Override the dev server command (space-separated).</dd>
<dt><code>--timeout-ms</code></dt>
<dd>Milliseconds to wait for the dev server (default from <code>app.zon</code>, or 30000).</dd>
</dl>
### `native build`
```sh
native build [dir]
```
Build a ReleaseFast binary into `zig-out/bin/`, printing a one-line completion and naming any failing step.
### `native test`
```sh
native test [dir]
```
Run the app's test suite, printing the zig build summary (step/test tally) plus a final `native test: passed` line. It also refreshes the model-contract artifact `native check` reads.
### `native check`
```sh
native check [dir] [--strict]
```
Validate the whole tree without building the app. A TypeScript core (`src/core.ts`) runs the subset checker first — real tsc semantics plus the app-core rules, diagnostics verbatim — then every `src/**.native` markup file and `app.zon` are checked as before. With a fresh model contract (`zig-out/model-contract.zon`, refreshed by `native test`) it also checks bindings, iterables, and message tags against your `Model`/`Msg` — for a TypeScript core, against its emitted model — and warns on model state no view uses. Without the artifact it degrades to structural checking and says so loudly: "model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks". Markup accessibility findings are reported per file in full, and a failing `src/*.native` file that no Zig source embeds gets a leftover-file hint.
<dl>
<dt><code>--strict</code></dt>
<dd>Fail on warnings.</dd>
</dl>
## App lifecycle
### `native eject`
```sh
native eject [dir]
```
Write an owned `build.zig`/`build.zig.zon` into the app (once); the verbs then drive your files via `zig build`. Ejecting is only for owning the build files — it is never a prerequisite: zero-config apps build, test, and [package](/packaging) directly (`native package` works on the zero-config build as-is).
### `native eject component`
```sh
native eject component <name> [dir]
```
Write an owned copy of a library composite into `src/components/` (once, never overwriting — ejecting again errors with the file to delete first). Ejectable today: `stepper`, `timeline`, `timeline-item` — the library views that are honest compositions of primitives; engine controls are not on the menu (theme them through [tokens](/theming) instead). `timeline` lands as a markup template (use it via `<use template="timeline" ...>`), the others as Zig view functions; each file opens with a header comment walking through the call-site migration, and each builds a widget tree identical to its library form at the moment of ejection. Unknown names get a did-you-mean plus the full ejectable list. See [Building Components](/building-components#use-eject-or-build).
### `native doctor`
```sh
native doctor [--strict] [--manifest app.zon] [--web-engine system|chromium] [--cef-dir path] [--cef-auto-install]
```
Check host environment, WebView, manifest, and CEF. See [native doctor](/debugging/doctor) for what each check means.
### `native validate`
```sh
native validate [app.zon]
```
Validate `app.zon` against the manifest schema.
### `native package`
```sh
native package [--target <macos|linux|windows|ios|android>] [flags]
```
Package the app for distribution. The manifest is picked up at `app.zon` and the binary at `zig-out/bin/<name>` automatically; the flags below override.
<dl>
<dt><code>--target</code></dt>
<dd>Target platform (<code>macos</code>, <code>linux</code>, <code>windows</code>, <code>ios</code>, <code>android</code>).</dd>
<dt><code>--manifest</code></dt>
<dd>Path to <code>app.zon</code>.</dd>
<dt><code>--output</code></dt>
<dd>Output path for the package.</dd>
<dt><code>--binary</code></dt>
<dd>Path to the built binary.</dd>
<dt><code>--assets</code></dt>
<dd>Path to frontend assets directory.</dd>
<dt><code>--optimize</code></dt>
<dd>Optimization level.</dd>
<dt><code>--web-engine</code></dt>
<dd>Temporarily override <code>app.zon</code> with <code>system</code> or macOS-only <code>chromium</code>.</dd>
<dt><code>--web-layer</code></dt>
<dd>Override <code>app.zon</code>'s <code>.webview_layer</code> with <code>auto</code>, <code>include</code>, or <code>exclude</code> — the same precedence as <code>-Dweb-layer</code> in the build graph. <code>zig build package</code> passes the graph's resolved decision here automatically so the package always matches the built executable.</dd>
<dt><code>--cef-dir</code></dt>
<dd>Temporarily override the CEF distribution path from <code>app.zon</code>.</dd>
<dt><code>--cef-auto-install</code></dt>
<dd>Temporarily allow prepared CEF installation during Chromium packaging.</dd>
<dt><code>--signing</code></dt>
<dd>Signing mode: <code>none</code>, <code>adhoc</code>, or <code>identity</code>.</dd>
<dt><code>--identity</code></dt>
<dd>Code signing identity name.</dd>
<dt><code>--entitlements</code></dt>
<dd>Path to entitlements file.</dd>
<dt><code>--team-id</code></dt>
<dd>Apple Developer Team ID.</dd>
<dt><code>--archive</code></dt>
<dd>Create a distributable archive.</dd>
</dl>
### Platform shortcuts
```sh
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]
```
Per-platform shortcuts for `native package --target <platform>`.
### `native bundle-assets`
```sh
native bundle-assets [app.zon] [assets] [output]
```
Copy frontend assets into the build output.
## Tooling
### `native markup`
```sh
native markup check <file.native> [more files...] [--strict]
native markup dump <file.native> [--out doc.nsui]
native markup lsp
```
Work with Native markup files directly, outside the app verbs.
<dl>
<dt><code>markup check</code></dt>
<dd>Validate Native markup without building; errors carry <code>file:line:column</code> and a teaching message. Run inside an app directory it picks up a fresh model contract the same way <code>native check</code> does; <code>--strict</code> promotes warnings to failures.</dd>
<dt><code>markup dump</code></dt>
<dd>Resolve and validate a view, encode the canonical NSUI binary, and print the JSON inspection view derived from the decoded binary; <code>--out</code> also writes the <code>.nsui</code> artifact.</dd>
<dt><code>markup lsp</code></dt>
<dd>Run the markup language server (diagnostics, completion, hover) for editor integration.</dd>
</dl>
### `native automate`
```sh
native automate <command>
```
Interact with the automation server of a running automation-enabled app. See [Automation](/automation) for the full workflow.
<dl>
<dt><code>automate list</code></dt>
<dd>List running automation-enabled apps.</dd>
<dt><code>automate snapshot</code></dt>
<dd>Dump current app state.</dd>
<dt><code>automate screenshot &lt;view-label&gt; [&lt;scale&gt;]</code></dt>
<dd>Render a <code>gpu_surface</code> view's canvas to <code>screenshot-&lt;view-label&gt;.png</code> through the deterministic CPU reference renderer.</dd>
<dt><code>automate reload</code></dt>
<dd>Reload the WebView.</dd>
<dt><code>automate resize &lt;width&gt; &lt;height&gt; [&lt;scale&gt;]</code></dt>
<dd>Dispatch a main-window resize event.</dd>
<dt><code>automate menu-command &lt;id&gt;</code></dt>
<dd>Dispatch a menu command event.</dd>
<dt><code>automate native-command &lt;id&gt; [&lt;view-label&gt;]</code></dt>
<dd>Dispatch a native view command event.</dd>
<dt><code>automate widget-action &lt;view-label&gt; &lt;widget-id&gt; &lt;action&gt; [&lt;value&gt;]</code></dt>
<dd>Invoke a retained canvas widget action.</dd>
<dt><code>automate widget-click &lt;view-label&gt; &lt;widget-id&gt;</code></dt>
<dd>Dispatch a pointer click at a retained canvas widget.</dd>
<dt><code>automate widget-hold &lt;view-label&gt; &lt;widget-id&gt;</code></dt>
<dd>Press-and-hold a retained canvas widget (drives <code>on_hold</code>).</dd>
<dt><code>automate widget-context-press &lt;view-label&gt; &lt;widget-id&gt;</code></dt>
<dd>Secondary-click a retained canvas widget (context menu, or <code>on_hold</code> when none).</dd>
<dt><code>automate widget-context-menu &lt;view-label&gt; &lt;widget-id&gt; &lt;item-index&gt;</code></dt>
<dd>Invoke a widget's declared context-menu item by index — the snapshot lists each widget's items in order.</dd>
<dt><code>automate widget-drag &lt;view-label&gt; &lt;widget-id&gt; &lt;start-x-ratio&gt; &lt;end-x-ratio&gt; [&lt;start-y-ratio&gt; &lt;end-y-ratio&gt;]</code></dt>
<dd>Dispatch pointer down/drag/up across a retained canvas widget.</dd>
<dt><code>automate widget-wheel &lt;view-label&gt; &lt;widget-id&gt; &lt;delta-y&gt;</code></dt>
<dd>Dispatch wheel input at a retained canvas widget.</dd>
<dt><code>automate widget-key &lt;view-label&gt; &lt;key&gt; [&lt;text&gt;]</code></dt>
<dd>Dispatch key input to the focused retained canvas widget.</dd>
<dt><code>automate shortcut &lt;id&gt;</code></dt>
<dd>Dispatch a shortcut command event.</dd>
<dt><code>automate tray-action &lt;item-id&gt;</code></dt>
<dd>Select a status-item dropdown row.</dd>
<dt><code>automate focus &lt;view-label&gt;</code></dt>
<dd>Focus a native or WebView-backed view.</dd>
<dt><code>automate focus-next</code> / <code>automate focus-previous</code></dt>
<dd>Move focus through visible, enabled views.</dd>
<dt><code>automate wait</code></dt>
<dd>Wait for <code>ready=true</code> in the snapshot.</dd>
<dt><code>automate assert [--absent] [--timeout-ms &lt;n&gt;] &lt;pattern&gt;...</code></dt>
<dd>Poll the snapshot until every regex pattern matches (or, with <code>--absent</code>, until none match); on timeout, prints the missing patterns plus the snapshot tail and exits non-zero.</dd>
<dt><code>automate bridge &lt;json&gt;</code></dt>
<dd>Send a bridge command (origin <code>zero://inline</code>).</dd>
</dl>
### `native skills`
```sh
native skills list
native skills get <name> [--full]
native skills get --all [--full]
```
List and print the built-in AI agent skills the CLI ships — the version-matched content the `npx skills add vercel-labs/native` discovery skill loads from. See [Agent Skills](/skills) for the one-command agent install, what each skill covers, and how to deliver one to an agent.
<dl>
<dt><code>skills list</code></dt>
<dd>List built-in skills served by the installed CLI.</dd>
<dt><code>skills get &lt;name&gt;</code></dt>
<dd>Output a skill's content (for example <code>skills get core</code> or <code>skills get native-ui</code>).</dd>
<dt><code>skills get --all</code></dt>
<dd>Output every non-hidden skill.</dd>
<dt><code>--full</code></dt>
<dd>Also include a skill's reference and template files when present.</dd>
</dl>
### `native cef`
```sh
native cef install|path|doctor [--dir path] [--version version] [--source prepared|official] [--force]
```
Manage the macOS CEF runtime for Chromium-engine apps.
<dl>
<dt><code>cef install</code></dt>
<dd>Download, prepare, and verify the macOS CEF runtime.</dd>
<dt><code>cef path</code></dt>
<dd>Print the default or configured CEF directory.</dd>
<dt><code>cef doctor</code></dt>
<dd>Check only the CEF layout.</dd>
</dl>
<dl>
<dt><code>--dir</code></dt>
<dd>CEF install directory. Defaults to the host platform directory under <code>third_party/cef</code>.</dd>
<dt><code>--version</code></dt>
<dd>CEF binary version to download. The default is Native SDK's pinned tested version.</dd>
<dt><code>--source</code></dt>
<dd><code>prepared</code> or <code>official</code>. Defaults to <code>prepared</code>, which downloads Native SDK's no-CMake runtime from GitHub Releases.</dd>
<dt><code>--download-url</code></dt>
<dd>Override the prepared runtime release base URL, or the official CEF host when using <code>--source official</code>.</dd>
<dt><code>--allow-build-tools</code></dt>
<dd>Allow the advanced official CEF path to invoke local build tools for <code>libcef_dll_wrapper</code>.</dd>
<dt><code>--force</code></dt>
<dd>Redownload and replace the target directory.</dd>
</dl>
Core maintainers who need to build CEF before a Native SDK runtime release exists should use `tools/cef/build-from-source.sh`. The CLI's default `native cef install` path remains the no-CMake app-developer path.
### `native version`
```sh
native version
```
Print the native version.
## Environment variables
<dl>
<dt><code>NATIVE_SDK_FRONTEND_URL</code></dt>
<dd>Dev server URL (read by <code>frontend.sourceFromEnv</code>).</dd>
<dt><code>NATIVE_SDK_FRONTEND_ASSETS</code></dt>
<dd>App convention for signaling pre-built assets.</dd>
<dt><code>NATIVE_SDK_LOG_DIR</code></dt>
<dd>Override log output directory.</dd>
<dt><code>NATIVE_SDK_LOG_FORMAT</code></dt>
<dd>Log format: <code>text</code> or <code>jsonl</code>.</dd>
</dl>
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("commands");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
+119
View File
@@ -0,0 +1,119 @@
# Commands
Commands are the shared routing layer for native app actions. Menus, shortcuts, toolbar controls, tray items, native controls, bridge calls, and runtime code can all dispatch the same command name into `Event.command`.
```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.refresh")) {
try runtime.updateView(command.window_id, "status", .{
.text = "Refreshed",
});
}
},
else => {},
}
}
```
## Sources
`CommandEvent.source` identifies where the action came from:
<table>
<thead>
<tr>
<th>Source</th>
<th>Emitted by</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>.runtime</code></td>
<td>Direct <code>runtime.dispatchCommand(...)</code> calls</td>
</tr>
<tr>
<td><code>.menu</code></td>
<td>Native menu items</td>
</tr>
<tr>
<td><code>.shortcut</code></td>
<td>Native keyboard shortcuts</td>
</tr>
<tr>
<td><code>.toolbar</code></td>
<td>Native controls inside a toolbar view</td>
</tr>
<tr>
<td><code>.tray</code></td>
<td>Tray actions</td>
</tr>
<tr>
<td><code>.native_view</code></td>
<td>Native controls outside toolbar views</td>
</tr>
<tr>
<td><code>.bridge</code></td>
<td><code>window.zero.commands.invoke(...)</code></td>
</tr>
</tbody>
</table>
Use `command.name` for the action and `command.source` only when the app genuinely needs source-specific behavior. Tray commands also include `tray_item_id` for apps that still need to distinguish legacy tray items that dispatch `"tray.action"`.
## Command names
Command names are stable IDs, not display labels. Use namespaced lowercase IDs:
```zig
const shortcuts = [_]native_sdk.Shortcut{
.{ .id = "app.refresh", .key = "r", .modifiers = .{ .primary = true } },
};
const view_items = [_]native_sdk.MenuItem{
.{ .label = "Refresh", .command = "app.refresh", .key = "r", .modifiers = .{ .primary = true } },
};
```
The same command can be bound to a menu item, shortcut, toolbar button, tray item, and bridge call. The runtime validates command IDs and rejects empty, oversized, or path-like names.
`app.zon` can also declare shared command metadata:
```zig
.commands = .{
.{ .id = "app.refresh", .title = "Refresh" },
.{ .id = "app.sidebar.toggle", .title = "Sidebar", .checked = true },
},
```
This metadata gives native and web entry points a common manifest-level command catalog while runtime dispatch still happens through `Event.command`.
Generated runners load that catalog into runtime options. Native code can read the active commands without reparsing the manifest:
```zig
var buffer: [32]native_sdk.Command = undefined;
const commands = runtime.listCommands(&buffer);
for (commands) |command| {
if (command.enabled) {
// Bind command.id to native controls or custom UI.
}
}
```
## Bridge invocation
When `js_window_api` and the built-in bridge policy allow it, WebView content can dispatch the same command:
```js
await window.zero.commands.invoke("app.refresh");
```
The resulting event has `source = .bridge`, the calling native `window_id`, and the calling view label when the call came from a named child WebView.
WebView content can also read the manifest command catalog:
```js
const commands = await window.zero.commands.list();
```
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/accordion");
export default function AccordionLayout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,64 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { CodeToggle } from "@/components/code-toggle";
# Accordion
A disclosure surface with a header. The header label comes from the `text` attribute, children show while `selected` is true, and a header press dispatches `on-toggle` — the model owns the open state, in the usual [modelmessage loop](/native-ui). Items render the house accordion look — borderless rows divided by hairline separators, with the chevron on the trailing edge rotating as the item expands — and size themselves: the header band alone while closed, header plus content while open, so a toggle reflows the column with no hand-managed heights.
<ComponentPreview name="accordion" alt="Open and closed accordion items rendered by the engine" caption="an open item and a closed one" />
## Markup
```html
<column width="380">
<accordion text="Is it accessible?" selected="{faq_open}" on-toggle="toggle_faq">
<column>
<text wrap="true" foreground="text_muted">Yes. Widgets carry semantic roles and one roving focus set.</text>
</column>
</accordion>
<accordion text="Is it styled?" on-toggle="toggle_styling" />
</column>
```
The core side is one flag and one arm flipping it:
<CodeToggle>
```ts
// model: { readonly faq_open: boolean }
case "toggle_faq":
return { ...model, faq_open: !model.faq_open };
```
```zig
// model: faq_open: bool = false,
.toggle_faq => model.faq_open = !model.faq_open,
```
</CodeToggle>
A one-open-at-a-time group is a model decision: store the open item's id and compute each item's `selected` from an equality, flipping it in `update`.
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.column(.{ .width = 380 }, .{
ui.el(.accordion, .{
.text = "Is it accessible?",
.selected = model.faq_open,
.on_toggle = .toggle_faq,
}, .{
ui.column(.{}, .{
ui.text(.{ .wrap = true, .style_tokens = .{ .foreground = .text_muted } }, "Yes. Widgets carry semantic roles and one roving focus set."),
}),
}),
ui.el(.accordion, .{ .text = "Is it styled?", .on_toggle = .toggle_styling }, .{}),
})
```
## Attributes
<AttrTable attrs={["text", "selected", "on-toggle", "height"]} />
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/alert");
export default function AlertLayout({ children }: { children: React.ReactNode }) {
return children;
}
+38
View File
@@ -0,0 +1,38 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Alert
An inline callout surface. The title comes from the `text` attribute and renders beside a fixed 16px icon aligned to its first line; `variant` colors the identity (`destructive` reads in the destructive hue with a warning icon). Children are the description: they start under the title and hang past the icon column, the standard callout grid. Like the other stacking surfaces it rejects `gap` — wrap multi-line descriptions in a `column`.
<ComponentPreview name="alert" alt="Default and destructive alerts rendered by the engine" caption="a titled alert with a description, and the destructive variant" />
## Markup
```html
<column gap="12" width="380">
<alert text="A new version of the shell is available.">
<text wrap="true" foreground="text_muted">Restart the app to finish updating.</text>
</alert>
<alert text="Your session has expired. Sign in again." variant="destructive"></alert>
</column>
```
For a message the user can dismiss, render the alert under an `if` and clear the flag from a button inside it — the model owns the visibility, as with every surface. For modal interruptions reach for [dialog](/components/dialog), [drawer](/components/drawer), or [sheet](/components/sheet) instead.
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.column(.{ .gap = 12, .width = 380 }, .{
ui.el(.alert, .{ .text = "A new version of the shell is available." }, .{
ui.text(.{ .wrap = true, .style_tokens = .{ .foreground = .text_muted } }, "Restart the app to finish updating."),
}),
ui.el(.alert, .{ .text = "Your session has expired. Sign in again.", .variant = .destructive }, .{}),
})
```
## Attributes
<AttrTable attrs={["text", "variant"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/avatar");
export default function AvatarLayout({ children }: { children: React.ReactNode }) {
return children;
}
+36
View File
@@ -0,0 +1,36 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Avatar
A pill-clipped image leaf with an initials fallback: the element's content renders as initials, and `image` takes one `{binding}` to a `u64` ImageId the app registered at runtime — from a Zig core with `fx.registerImageBytes`, or declared once in `app.zon`'s `assets.images`, which registers each file at launch. The id `0` is the "no image" sentinel, so an app shows initials while the image loads — and keeps them on a failed fetch or decode — by only writing the id into its model on successful registration.
<ComponentPreview name="avatar" alt="Avatars rendered by the engine" caption="initials fallbacks" />
## Markup
```html
<row gap="12" cross="center">
<avatar>ZN</avatar>
<avatar>CT</avatar>
<avatar image="{profile_image}" label="Profile picture">NS</avatar>
</row>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.avatar(.{}, "ZN")
```
With a registered image the engine clips it to the avatar circle (`cover` fit); the initials remain the fallback while the id is 0:
```zig
ui.avatar(.{ .image = image_id }, "ZN")
```
## Attributes
<AttrTable element="avatar" attrs={["text", "image", "label"]} />
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/badge");
export default function BadgeLayout({ children }: { children: React.ReactNode }) {
return children;
}
+44
View File
@@ -0,0 +1,44 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Badge
A text leaf for counts and statuses: the content is the label (with `{}` interpolation), `variant` picks the color treatment, and an optional `icon` draws a built-in vector icon inline before the text. Badges are not interactive — for a pressable chip see [toggle-button](/components/toggle) or [button](/components/button).
<ComponentPreview name="badge" alt="Badge variants rendered by the engine" caption="default, secondary, outline, destructive, and icon badges" />
## Markup
```html
<row gap="10" cross="center">
<badge>Badge</badge>
<badge variant="secondary">Secondary</badge>
<badge variant="outline">Outline</badge>
<badge variant="destructive">Destructive</badge>
<badge variant="secondary" icon="check">Verified</badge>
</row>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.row(.{ .gap = 10, .cross = .center }, .{
ui.el(.badge, .{ .text = "Badge" }, .{}),
ui.el(.badge, .{ .text = "Secondary", .variant = .secondary }, .{}),
ui.el(.badge, .{ .text = "Outline", .variant = .outline }, .{}),
ui.el(.badge, .{ .text = "Destructive", .variant = .destructive }, .{}),
ui.el(.badge, .{ .text = "Verified", .variant = .secondary, .icon = "check" }, .{}),
})
```
## Attributes
<AttrTable
attrs={[
"text",
"variant",
"icon",
]}
/>
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/breadcrumb");
export default function BreadcrumbLayout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,50 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Breadcrumb
A row container for a hierarchy trail — children flow horizontally, and the trail is plain composition: muted `text` leaves for the ancestors, a muted `chevron-right` [icon](/components/icon) between them, and an unmuted leaf for the current page. Binding `on-press` on a text leaf makes it pressable, so each ancestor can navigate.
<ComponentPreview name="breadcrumb" alt="A breadcrumb trail rendered by the engine" caption="muted ancestors, chevron separators, and the current page" />
## Markup
```html
<breadcrumb gap="8" cross="center">
<text foreground="text_muted" on-press="open_home">Home</text>
<icon name="chevron-right" foreground="text_muted" />
<text foreground="text_muted" on-press="open_components">Components</text>
<icon name="chevron-right" foreground="text_muted" />
<text>Breadcrumb</text>
</breadcrumb>
```
For a model-driven trail, loop the ancestors and dispatch a payload per crumb:
```html
<breadcrumb gap="8" cross="center">
<for each="crumbs" as="crumb" key="id">
<text foreground="text_muted" on-press="open_crumb:{crumb.id}">{crumb.title}</text>
<icon name="chevron-right" foreground="text_muted" />
</for>
<text>{current_title}</text>
</breadcrumb>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.el(.breadcrumb, .{ .gap = 8, .cross = .center }, .{
ui.text(.{ .style_tokens = .{ .foreground = .text_muted }, .on_press = .open_home }, "Home"),
ui.icon(.{ .style_tokens = .{ .foreground = .text_muted } }, "chevron-right"),
ui.text(.{ .style_tokens = .{ .foreground = .text_muted }, .on_press = .open_components }, "Components"),
ui.icon(.{ .style_tokens = .{ .foreground = .text_muted } }, "chevron-right"),
ui.text(.{}, "Breadcrumb"),
})
```
## Attributes
<AttrTable attrs={["gap", "cross", "on-press"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/bubble");
export default function BubbleLayout({ children }: { children: React.ReactNode }) {
return children;
}
+69
View File
@@ -0,0 +1,69 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Bubble
`bubble` is the chat-message surface: a capsule-cornered fill that hugs its message (10px vertical, 12px horizontal — one body line reads as a full pill). Children stack inside, and `variant` picks the side of the conversation: the default muted wash is the received side, `primary` is the sent side — its fill re-inks the text inside automatically, so you never hand-pick knockout ink per message. `outline`, `destructive`, and `ghost` round out the register. Like every stacking surface it rejects `gap`; override the built-in hug with `padding` if you must.
A bubble never spans its thread: with no explicit `width` it hugs its message up to **80% of the container's width**, then the message wraps — one long paragraph reads as a column, not a wall. `ghost` is exempt (its content carries the message full-width), and an explicit `width` always wins. Because bubbles hug, a bubble placed directly in a column sits on the leading edge like a received message; give the column `cross="end"` for the sent side.
<ComponentPreview name="bubble" alt="A received run of two grouped chat bubbles, one carrying a reaction pill, and a sent primary bubble" caption="a grouped received run with a reaction pill, and a primary sent turn" />
## Grouped runs
Consecutive messages from the same sender read as a group, and grouping is spacing, not vocabulary: the reference keeps full capsule corners on every bubble in a run, so there is no group container to reach for. Use the measured rhythm — **8px between bubbles within a run, 32px between turns** — with plain columns:
```html
<column gap="32" width="340">
<column gap="8">
<bubble>
<text wrap="true">Ready to ship the components page?</text>
</bubble>
<bubble>
<text wrap="true">The previews and the docs page both need one more pass before we cut it.</text>
<reactions>+2</reactions>
</bubble>
</column>
<column gap="8" cross="end">
<bubble variant="primary">
<text wrap="true">Previews are rendering now.</text>
</bubble>
</column>
</column>
```
## Reactions
A `reactions` child docks the reaction pill: one small muted capsule straddling the bubble's bottom edge — a quarter overlapping the capsule, three quarters hanging below, separated from the fill by a ring in the page background. The pill holds one run of text (`{bindings}` work) and docks per `text-alignment`: `end` is the default trailing corner, `start` and `center` are the alternatives. It draws on the page plane, so a `primary` bubble's knockout ink never applies to it.
The pill consumes no layout space — like the reference, it overlaps whatever sits below, which is exactly why the 32px turn gap matters: it is the overlap's breathing room. One pill per bubble; it is chrome, not content, so events, keys, and layout stay on the bubble. Literal pill text rides the same font-coverage guard as every rendered label — emoji outside the bundled face are a teaching error (bind them from model data for live apps, where the platform text stack renders them; the reference/screenshot path cannot).
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. The pill rides the bubble's chrome-text channels directly (the same convention alert titles use): `text` is the run, `text_alignment` the dock. Note the builder's `text_alignment` default is `.start`; markup's `<reactions>` defaults to the reference's trailing dock, so set `.end` explicitly here.
```zig
ui.column(.{ .gap = 32, .width = 340 }, .{
ui.column(.{ .gap = 8 }, .{
ui.el(.bubble, .{}, .{
ui.text(.{ .wrap = true }, "Ready to ship the components page?"),
}),
ui.el(.bubble, .{ .text = "+2", .text_alignment = .end }, .{
ui.text(.{ .wrap = true }, "The previews and the docs page both need one more pass before we cut it."),
}),
}),
ui.column(.{ .gap = 8, .cross = .end }, .{
ui.el(.bubble, .{ .variant = .primary }, .{
ui.text(.{ .wrap = true }, "Previews are rendering now."),
}),
}),
})
```
## Attributes
<AttrTable attrs={["padding", "variant"]} />
On the `reactions` child:
<AttrTable attrs={["text-alignment"]} element="reactions" />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/button-group");
export default function ButtonGroupLayout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,61 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Button Group
A row container that attaches related action [buttons](/components/button) into one segmented bar. At the default gap of 0 the engine collapses the group into a single shape: the first segment keeps its leading corners, the last its trailing corners, middles square off, and each interior boundary is drawn by exactly one shared 1px seam. Every segment stays its own button — its own label, its own `on-press`, its own disabled state.
For an exclusive active segment use a [toggle-group](/components/toggle) (pressed state is the toggle family's job) or [tabs](/components/tabs); a button group is attached *actions*, never a choice.
<ComponentPreview name="button-group" alt="A flush button group rendered by the engine" caption="three outline actions attached into one bar with shared seams" />
## Markup
```html
<button-group>
<button variant="outline" on-press="cut">Cut</button>
<button variant="outline" on-press="copy">Copy</button>
<button variant="outline" on-press="paste">Paste</button>
</button-group>
```
## Variants and icon segments
Grouped buttons keep their variant's fill — a primary or secondary group reads as one filled bar with the same seam collapse — and `icon`-sized segments make compact tool clusters (a pager, a zoom control). Mixing variants inside one group is possible but usually reads as a mistake; pick one register per bar.
```html
<button-group>
<button variant="outline" icon="chevron-left" on-press="prev">Back</button>
<button variant="outline" on-press="today">Today</button>
<button variant="outline" icon="chevron-right" icon-placement="trailing" on-press="next">Next</button>
</button-group>
```
## Spacing out
`gap` above 0 opts out of the attached treatment: the children render as ordinary separate buttons with full corners and borders. Use a plain [row](/components/layout) if the actions are not related enough to attach.
```html
<button-group gap="8">
<button variant="outline" on-press="approve">Approve</button>
<button variant="outline" on-press="reject">Reject</button>
</button-group>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.el(.button_group, .{}, .{
ui.button(.{ .variant = .outline, .on_press = .cut }, "Cut"),
ui.button(.{ .variant = .outline, .on_press = .copy }, "Copy"),
ui.button(.{ .variant = .outline, .on_press = .paste }, "Paste"),
})
```
## Attributes
<AttrTable attrs={["gap", "cross"]} />
Children are ordinary buttons; see the [button attributes](/components/button) for the per-segment surface.
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/button");
export default function ButtonLayout({ children }: { children: React.ReactNode }) {
return children;
}
+90
View File
@@ -0,0 +1,90 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Button
A text-bearing press control. The label is the element's content, `on-press` dispatches its Msg, and an inline `icon` draws before the label as part of the same hit target. Six variants cover the house palette; previews are rendered by the engine itself.
<ComponentPreview name="button" alt="Button variants rendered by the engine" caption="default, primary, secondary, outline, ghost, and destructive variants" />
## Markup
```html
<row gap="12" cross="center">
<button variant="primary" on-press="save">Save</button>
<button variant="secondary" on-press="duplicate">Duplicate</button>
<button variant="outline" on-press="preview">Preview</button>
<button variant="ghost" on-press="dismiss">Dismiss</button>
<button variant="destructive" on-press="delete_note">Delete</button>
</row>
```
## Sizes
Four sizes: `sm`, `default`, `lg`, and `icon` — the icon size renders a square, icon-only button (give it a `label` for accessibility).
<ComponentPreview name="button-sizes" alt="Button sizes rendered by the engine" caption="sm, default, lg, and icon sizes" />
```html
<row gap="12" cross="center">
<button size="sm" variant="outline" on-press="zoom_out">Small</button>
<button variant="outline" on-press="reset_zoom">Default</button>
<button size="lg" variant="outline" on-press="zoom_in">Large</button>
<button size="icon" icon="plus" label="New note" on-press="create_note"></button>
</row>
```
## Icons
`icon` names a built-in vector icon (see the [icon registry](/components/icon)) drawn inline before the label — one hit target, one enabled/disabled tint.
<ComponentPreview name="button-icons" alt="Buttons with inline icons rendered by the engine" />
```html
<row gap="12" cross="center">
<button variant="primary" icon="download" on-press="download">Download</button>
<button variant="outline" icon="git-branch" on-press="new_branch">New Branch</button>
<button disabled="{saving}" on-press="save">Save</button>
</row>
```
## States
Hover and press styling is engine-owned render state; `disabled` is a source attribute (a literal or a model binding).
<ComponentPreview name="button-states" alt="Button default, hovered, and disabled states rendered by the engine" caption="default, hovered (engine render state), and disabled" />
## Button Group
[`button-group`](/components/button-group) attaches related action buttons into one segmented bar — flush segments, one shared corner language, one interior seam. It has its own page.
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.row(.{ .gap = 12, .cross = .center }, .{
ui.button(.{ .variant = .primary, .on_press = .save }, "Save"),
ui.button(.{ .variant = .secondary, .on_press = .duplicate }, "Duplicate"),
ui.button(.{ .variant = .outline, .on_press = .preview }, "Preview"),
ui.button(.{ .variant = .ghost, .on_press = .dismiss }, "Dismiss"),
ui.button(.{ .variant = .destructive, .on_press = .delete_note }, "Delete"),
})
```
## Attributes
<AttrTable
attrs={[
"text",
"variant",
"size",
"icon",
"disabled",
"selected",
"autofocus",
"label",
"on-press",
"on-hold",
]}
/>
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/card");
export default function CardLayout({ children }: { children: React.ReactNode }) {
return children;
}
+51
View File
@@ -0,0 +1,51 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Card
`card` is the bordered, elevated surface container. It is a stacking container — children layer on top of each other and `gap` is rejected — so put a single `column` (or `row`) inside for flow. Cards carry 24px of content padding by default (the house inset; 16 at `size="sm"`) — set `padding` explicitly to override it. Binding `on-press` makes the whole surface pressable, the list-of-cards pattern. For the plain surface with the same stacking contract, see [panel](/components/panel).
<ComponentPreview name="card" alt="A card rendered by the engine" />
## Markup
```html
<card width="340">
<column gap="12">
<text>Deploy your app</text>
<text wrap="true" foreground="text_muted">The runtime packages your views, commands, and assets into one native binary.</text>
<row gap="8">
<button variant="primary" on-press="deploy">Deploy</button>
<button variant="ghost" on-press="cancel">Cancel</button>
</row>
</column>
</card>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.el(.card, .{ .width = 340 }, .{
ui.column(.{ .gap = 12 }, .{
ui.text(.{}, "Deploy your app"),
ui.text(.{ .wrap = true, .style_tokens = .{ .foreground = .text_muted } }, "The runtime packages your views, commands, and assets into one native binary."),
ui.row(.{ .gap = 8 }, .{
ui.button(.{ .variant = .primary, .on_press = .deploy }, "Deploy"),
ui.button(.{ .variant = .ghost, .on_press = .cancel }, "Cancel"),
}),
}),
})
```
## Attributes
<AttrTable
attrs={[
"text",
"padding",
"width",
"on-press",
]}
/>
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/chart");
export default function ChartLayout({ children }: { children: React.ReactNode }) {
return children;
}
+130
View File
@@ -0,0 +1,130 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { CodeToggle } from "@/components/code-toggle";
# Chart
A data chart: line, area (a line filled to the baseline), and bar series over uniform x steps, drawn through the vector path pipeline with token-driven colors — charts retheme with the palette, repaint exactly when their data changes, and report a series summary to automation and assistive tech. Series are copied into the build arena and downsampled deterministically to the per-series point budget (index-bucket min/max, spikes preserved), so a 10k-point series renders instead of erroring. Axis tick labels are opt-in and draw in the muted text register inside reserved gutters; `hover-details` adds a pointer-hover detail card. Presses fall through like text — with hover details on, the chart is a hover target only.
<ComponentPreview name="chart" alt="A line chart rendered by the engine" caption="two line series over monthly categories with gridlines and muted axis tick labels — hover the live preview for per-sample details" />
## Markup
A `<chart>` takes one or more `<series>` children. Each series binds its data with `values="{binding}"` naming a model iterable of `f32` — a field, a public declaration, or a function (arena functions work), the same sources `for each` accepts. Axis bounds are literals or scalar bindings. The chart above is two `line` series sharing one labeled frame: category labels on x, nice-step ticks on y, and gridlines riding the same tick lattice.
```html
<chart height="180" grid-lines="3" x-labels="{months}" y-labels="true" hover-details="true" label="Request latency">
<series kind="line" values="{p95_ms}" label="p95" />
<series kind="line" values="{p50_ms}" color="text_muted" label="p50" />
</chart>
```
The series set is static — the plot's shape is part of the design, and the data varies through the bindings. `NaN` samples pass through and draw nothing, so a history shorter than its window enters from the right edge: pad the leading gap in a model function.
<CodeToggle>
```ts
/// The 60-sample window, NaN-padded at the front while samples accumulate.
export function cpuSpark(model: Model): readonly number[] {
const out: number[] = [];
for (let i = model.cpuHistory.length; i < 60; i++) out.push(NaN);
for (const sample of model.cpuHistory) out.push(sample);
return out;
}
```
```zig
/// The 60-sample window, NaN-padded at the front while samples accumulate.
pub fn cpuSpark(model: *const Model, arena: std.mem.Allocator) []const f32 {
const out = arena.alloc(f32, 60) catch return &.{};
@memset(out, std.math.nan(f32));
@memcpy(out[60 - model.cpu_len ..], model.cpuHistory());
return out;
}
```
</CodeToggle>
Unlabeled charts get a generated series summary (`chart: line cpu 60 pts last 0.42`) as their accessible name, so automation can assert on the data without pixels; `label` replaces it.
## Bar
`kind="bar"` draws one column per sample. Bars always force 0 into a derived y domain — a bar's area IS its value, so the baseline is an honest zero, and `baseline="true"` marks it with a hairline. Category labels suit bars: one x label per column, thinned to every Nth when they would collide.
<ComponentPreview name="chart-bar" alt="A bar chart rendered by the engine" caption="a bar series over weekday categories with a zero-baseline hairline — hover the live preview for the day's count" />
```html
<chart height="180" baseline="true" x-labels="{weekdays}" hover-details="true" label="Builds per day">
<series kind="bar" values="{daily_builds}" label="builds" />
</chart>
```
## Area
`kind="area"` is a line filled to the baseline — the register for a quantity where the mass under the curve reads, like a rolling resource window. Explicit `y-min`/`y-max` pin the domain (here the 0..1 fraction of a fixed budget) so the fill's height stays comparable as new samples arrive instead of rescaling to the data. A rolling window has no category labels, so this one carries none — the hover card titles the hovered sample by index.
<ComponentPreview name="chart-area" alt="An area chart rendered by the engine" caption="a line series filled to the baseline on a pinned 0..1 domain — hover the live preview for the sample's value" />
```html
<chart height="160" y-min="0" y-max="1" grid-lines="3" hover-details="true" label="Memory">
<series kind="area" values="{memorySpark}" label="memory" />
</chart>
```
## Axis labels
`x-labels="{binding}"` names a model iterable of strings — one category label per sample, oldest first (label i names sample i). Labels draw muted below the plot and thin deterministically to every Nth so they never collide; they are dropped when a series downsamples (the bucketed indices no longer name the labeled samples). `y-labels="true"` adds numeric ticks in a measured gutter left of the plot: values ride a nice-step lattice (1, 2, or 5 times a power of ten), so every label is exact at its precision, and with `grid-lines` set the gridlines share that lattice — the grid and its labels can never disagree.
## Hover details
`hover-details="true"` makes the plot hoverable: the pointer snaps to the nearest sample, a hairline cursor marks it (line series get a dot on the hovered point), and a floating card shows the sample's category label with every series' name and value. The card prefers the right side of the cursor and flips left at the window edge. This is interaction-only chrome — static renders never show it — and presses still fall through to whatever is around the chart. Every live preview on this page has it on: hover any of the three.
## Attributes
<AttrTable element="chart" attrs={["y-min", "y-max", "grid-lines", "baseline", "x-labels", "y-labels", "hover-details", "stroke-width", "width", "height", "grow", "padding", "key", "global-key", "label"]} />
### Series
`values` is required on every series.
<AttrTable element="series" attrs={["kind", "values", "color", "label"]} />
## Programmatic construction (Zig)
The builder is the escape hatch for what markup deliberately excludes: `.band` series (a min/max envelope needs a paired lower-edge slice per point) and series sets composed dynamically at view time. `ui.chart` takes `ChartOptions` and a slice of `canvas.ChartSeries` (kinds `.line`, `.bar`, `.band`; `fill` shades the area under a line — markup's `kind="area"`).
```zig
ui.chart(.{
.width = 420,
.height = 180,
.grid_lines = 3,
.baseline = true,
.x_labels = &month_labels,
.y_labels = true,
.hover_details = true,
}, &.{
.{ .kind = .line, .fill = true, .label = "cpu", .values = model.cpu_samples },
.{ .kind = .bar, .color = .text_muted, .label = "jobs", .values = model.job_counts },
})
```
## Options
`ChartOptions` fields (the builder-side equivalent of the attributes table):
| Option | Description |
| --- | --- |
| `width` | Definite plot width (same contract as `ElementOptions.width`); 0 keeps the intrinsic sparkline-sized default (160x48). |
| `height` | Definite plot height (same contract as `ElementOptions.height`); 0 keeps the intrinsic sparkline-sized default. |
| `grow` | Flex grow factor; flexes the plot instead of a definite size. |
| `padding` | Uniform padding (plain number). |
| `y_min` | Explicit y-domain minimum; null derives it from the data (bar series force 0 into a derived domain — bars always have an honest zero baseline). |
| `y_max` | Explicit y-domain maximum; null derives it from the data. |
| `grid_lines` | Horizontal token-hairline gridlines (0 = none; gridlines are opt-in, never default). At even divisions of the plot — or on the y-label tick lattice when `y_labels` is on, so grid and labels share positions. |
| `baseline` | Hairline at the baseline (zero clamped into the domain). |
| `x_labels` | Category labels for the x axis, one per sample index (empty = no x axis). Muted, in a reserved gutter, thinned to fit; dropped when a series downsamples. |
| `y_labels` | Numeric y tick labels on the nice-step lattice, in a measured gutter left of the plot. |
| `hover_details` | Pointer-hover point details: snap cursor, per-point dots on line series, and a floating card with the sample's label and every series' value. Interaction-only; presses still fall through. |
| `stroke_width` | Line stroke width override (default 1.5). |
| `key` / `global_key` | Widget identity, as on any element. |
| `semantics` | Widget semantics; the role defaults to `chart`, an empty label gets a generated series summary, and the accessibility value reports the first series' latest point. |
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/checkbox");
export default function CheckboxLayout({ children }: { children: React.ReactNode }) {
return children;
}
+59
View File
@@ -0,0 +1,59 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { CodeToggle } from "@/components/code-toggle";
# Checkbox
A binary value control: the label rides the `text` attribute — checkbox is not a text-bearing element, so text content between the tags is rejected with a teaching error (`label="..."` alone names one for accessibility without a visible label). The model binds `checked`, and `on-toggle` dispatches its Msg — the engine never flips state on its own. For a single choice among options, use [radio](/components/radio); for an on/off setting rendered as a sliding thumb, use [switch](/components/switch).
<ComponentPreview name="checkbox" alt="Checkboxes rendered by the engine" caption="checked, unchecked, and disabled checkboxes" />
## Markup
```html
<column gap="12">
<checkbox checked="{accepted}" on-toggle="toggle_terms" text="Accept terms and conditions" />
<checkbox checked="{reports}" on-toggle="toggle_reports" text="Send usage reports" />
<checkbox checked="true" disabled="true" text="Managed by your organization" />
</column>
```
The core side is one boolean per box and one arm flipping it:
<CodeToggle>
```ts
// model: { readonly accepted: boolean }
case "toggle_terms":
return { ...model, accepted: !model.accepted };
```
```zig
// model: accepted: bool = false,
.toggle_terms => model.accepted = !model.accepted,
```
</CodeToggle>
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.column(.{ .gap = 12 }, .{
ui.checkbox(.{ .text = "Accept terms and conditions", .checked = model.accepted, .on_toggle = .toggle_terms }),
ui.checkbox(.{ .text = "Send usage reports", .checked = model.reports, .on_toggle = .toggle_reports }),
ui.checkbox(.{ .text = "Managed by your organization", .checked = true, .disabled = true }),
})
```
## Attributes
<AttrTable
attrs={[
"text",
"checked",
"disabled",
"on-toggle",
]}
/>
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/combobox");
export default function ComboboxLayout({ children }: { children: React.ReactNode }) {
return children;
}
+61
View File
@@ -0,0 +1,61 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Combobox
`combobox` is a trigger-only primitive like [select](/components/select), but the trigger is a text entry with a menu affordance: `on-input` names a Msg variant that receives every edit as a text-input event (`canvas.TextInputEvent` in a Zig core; the `TextInputEvent` union from `@native-sdk/core/text` in a TypeScript core), and the model filters the options as the user types. The options themselves are composed the same way as the select's — an anchored [dropdown-menu](/components/dropdown-menu) of menu-items beside the trigger in a `stack`, rendered under an `if`, with `on-dismiss` clearing the model's open flag when Escape or a click outside closes the surface.
<ComponentPreview name="combobox" alt="A combobox rendered by the engine" caption="a combobox trigger with its search placeholder" />
## Markup
The model owns the query and the open flag; the `for` source is the model-filtered match list, so the menu narrows as the user types.
```html
<stack width="240">
<combobox placeholder="Search frameworks" text="{framework_query}" on-input="framework_edited" on-press="open_framework_menu" />
<if test="{framework_menu_open}">
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_framework_menu">
<for each="matchingFrameworks" key="id" as="f">
<menu-item on-press="pick_framework:{f.id}">{f.name}</menu-item>
</for>
</dropdown-menu>
</if>
</stack>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. `on_input` takes a comptime message constructor: `Ui.inputMsg(.tag)` builds `Msg{ .tag = edit }` for each `canvas.TextInputEvent`.
```zig
ui.stack(.{ .width = 240 }, .{
ui.el(.combobox, .{
.placeholder = "Search frameworks",
.text = model.framework_query,
.on_input = Ui.inputMsg(.framework_edited),
.on_press = .open_framework_menu,
}, .{}),
if (model.framework_menu_open) ui.el(.dropdown_menu, .{
.anchor = .below,
.anchor_alignment = .stretch,
.on_dismiss = .close_framework_menu,
}, .{
ui.el(.menu_item, .{ .text = "Native SDK", .on_press = .{ .pick_framework = 0 } }, .{}),
ui.el(.menu_item, .{ .text = "Canvas", .on_press = .{ .pick_framework = 1 } }, .{}),
}) else ui.spacer(0),
})
```
## Attributes
<AttrTable
attrs={[
"text",
"placeholder",
"disabled",
"on-press",
"on-input",
"on-dismiss",
]}
/>
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/dialog");
export default function DialogLayout({ children }: { children: React.ReactNode }) {
return children;
}
+75
View File
@@ -0,0 +1,75 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { CodeToggle } from "@/components/code-toggle";
# Dialog
A modal dialog surface rendered in place: the title comes from the `text` attribute, and visibility is model-owned — wrap the dialog in an `if` on an open flag. `on-dismiss` dispatches when Escape or a click outside dismisses the surface, so `update` clears the flag; the engine hides the surface immediately as an optimistic echo, and the source tree wins on the next rebuild. The title is drawn by the surface chrome and children stack over the full content box, so lead the body column with a fixed-height spacer that clears the title line. For edge-anchored surfaces with the same contract, see [drawer](/components/drawer) and [sheet](/components/sheet).
<ComponentPreview name="dialog" alt="A modal dialog rendered by the engine" caption="title chrome, body content, and a trailing action row" />
## Markup
```html
<column padding="24">
<button variant="outline" on-press="open_rename">Rename note</button>
<if test="{rename_open}">
<dialog text="Rename note" width="380" height="240" padding="24" on-dismiss="close_rename">
<column gap="14">
<spacer height="34"></spacer>
<text wrap="true" foreground="text_muted">The new name shows up everywhere this note is linked.</text>
<input text="{draft_name}" label="New name" on-input="name_edited" on-submit="confirm_rename"></input>
<row gap="8" main="end">
<button variant="ghost" on-press="close_rename">Cancel</button>
<button variant="primary" on-press="confirm_rename">Rename</button>
</row>
</column>
</dialog>
</if>
</column>
```
The open flag is the whole core contract — one field, one arm setting it, one clearing it (`on-dismiss` and the Cancel button share the close arm):
<CodeToggle>
```ts
// model: { readonly rename_open: boolean }
case "open_rename":
return { ...model, rename_open: true };
case "close_rename":
return { ...model, rename_open: false };
```
```zig
// model: rename_open: bool = false,
.open_rename => model.rename_open = true,
.close_rename => model.rename_open = false,
```
</CodeToggle>
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. The dialog is a plain element kind, so the builder form is `ui.el(.dialog, ...)`; build it only while the model's open flag is set.
```zig
// Rendered only while model.rename_open is true; on_dismiss clears it in update.
ui.el(.dialog, .{ .text = "Rename note", .width = 380, .height = 240, .padding = 24, .on_dismiss = .close_rename }, .{
ui.column(.{ .gap = 14 }, .{
ui.column(.{ .height = 34 }, .{}), // clears the engine-drawn title line
ui.text(.{ .wrap = true, .style_tokens = .{ .foreground = .text_muted } }, "The new name shows up everywhere this note is linked."),
ui.el(.input, .{ .text = model.draft_name, .on_input = Ui.inputMsg(.name_edited), .on_submit = .confirm_rename, .semantics = .{ .label = "New name" } }, .{}),
ui.row(.{ .gap = 8, .main = .end }, .{
ui.button(.{ .variant = .ghost, .on_press = .close_rename }, "Cancel"),
ui.button(.{ .variant = .primary, .on_press = .confirm_rename }, "Rename"),
}),
}),
})
```
## Attributes
Note that `gap` is rejected on the dialog itself — it layers children like the other stacking surfaces; put a column (or row) inside for flow.
<AttrTable attrs={["text", "width", "height", "padding", "on-dismiss"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/drawer");
export default function DrawerLayout({ children }: { children: React.ReactNode }) {
return children;
}
+48
View File
@@ -0,0 +1,48 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Drawer
A side-anchored surface with the same contract as [dialog](/components/dialog): rendered in place, title via the `text` attribute, visibility model-owned behind an `if`, and `on-dismiss` dispatched on Escape or click-outside so `update` clears the open flag. As with the dialog, the title chrome is engine-drawn and children stack over the full content box, so lead the body column with a fixed-height spacer. For the bottom-edge variant, see [sheet](/components/sheet).
<ComponentPreview name="drawer" alt="A drawer surface rendered by the engine" caption="a side drawer with a title and stacked controls" />
## Markup
```html
<column padding="24">
<button variant="outline" icon="menu" on-press="open_filters">Filters</button>
<if test="{filters_open}">
<drawer text="Filters" width="260" padding="24" on-dismiss="close_filters">
<column gap="12">
<spacer height="34"></spacer>
<checkbox checked="{only_unread}" on-toggle="toggle_unread">Only unread</checkbox>
<checkbox checked="{has_attachments}" on-toggle="toggle_attachments">Has attachments</checkbox>
<switch checked="{compact_rows}" on-toggle="toggle_compact">Compact rows</switch>
</column>
</drawer>
</if>
</column>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
// Rendered only while model.filters_open is true; on_dismiss clears it in update.
ui.el(.drawer, .{ .text = "Filters", .width = 260, .padding = 24, .on_dismiss = .close_filters }, .{
ui.column(.{ .gap = 12 }, .{
ui.column(.{ .height = 34 }, .{}), // clears the engine-drawn title line
ui.checkbox(.{ .text = "Only unread", .checked = model.only_unread, .on_toggle = .toggle_unread }),
ui.checkbox(.{ .text = "Has attachments", .checked = model.has_attachments, .on_toggle = .toggle_attachments }),
ui.el(.switch_control, .{ .text = "Compact rows", .checked = model.compact_rows, .on_toggle = .toggle_compact }, .{}),
}),
})
```
## Attributes
The drawer rejects `gap` — it layers children like the other stacking surfaces; put a column (or row) inside for flow.
<AttrTable attrs={["text", "width", "height", "padding", "on-dismiss"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/dropdown-menu");
export default function DropdownMenuLayout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,76 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Dropdown Menu
A vertical menu surface whose children are `menu-item` elements. Setting `anchor="below"` (or `above`) floats the surface against its parent's frame instead of the flow: it paints in a late z-pass above the whole tree, is clipped only by the window (never by a scroll pane), and auto-flips to the other side when it does not fit. The sanctioned shape is a `stack` that wraps the trigger and, under an `if`, the anchored dropdown as its sibling — the model owns the open flag, and `on-dismiss` closes it on Escape or a click outside.
<ComponentPreview name="dropdown-menu" alt="An open dropdown menu anchored below its trigger button" caption="an open menu anchored below its trigger" />
## Markup
```html
<stack>
<button variant="outline" icon="chevron-down" on-press="toggle_actions">Actions</button>
<if test="{actions_open}">
<dropdown-menu anchor="below" min-width="200" on-dismiss="close_actions">
<menu-item text="Duplicate" icon="copy" on-press="duplicate_note" />
<menu-item text="Rename" icon="edit" on-press="rename_note" />
<menu-item text="Download" icon="download" on-press="download_note" />
<separator />
<menu-item text="Delete" icon="trash" on-press="delete_note" />
</dropdown-menu>
</if>
</stack>
```
Handle the item's Msg in `update` and clear the open flag there — a menu press does not dismiss implicitly. [Select](/components/select) and [combobox](/components/combobox) build their option lists from exactly this pattern (with `anchor-alignment="stretch"` for the select-menu width).
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.stack(.{}, .{
ui.button(.{ .variant = .outline, .icon = "chevron-down", .on_press = .toggle_actions }, "Actions"),
if (model.actions_open) ui.el(.dropdown_menu, .{
.anchor = .below,
.min_width = 200,
.on_dismiss = .close_actions,
}, .{
ui.el(.menu_item, .{ .text = "Duplicate", .icon = "copy", .on_press = .duplicate_note }, .{}),
ui.el(.menu_item, .{ .text = "Rename", .icon = "edit", .on_press = .rename_note }, .{}),
ui.el(.menu_item, .{ .text = "Download", .icon = "download", .on_press = .download_note }, .{}),
ui.separator(.{}),
ui.el(.menu_item, .{ .text = "Delete", .icon = "trash", .on_press = .delete_note }, .{}),
}) else ui.stack(.{}, .{}),
})
```
## Static menu surface
The builder also has `menu_surface`, an in-flow (non-floating) menu panel with the same `menu_item` children — useful for command palettes and always-visible menus. It is a documented markup exclusion: write it as a Zig view.
<ComponentPreview name="menu" alt="A static menu surface with menu items rendered by the engine" caption="a static menu_surface with icons, a disabled item, and a separator" />
```zig
ui.el(.menu_surface, .{ .min_width = 220 }, .{
ui.el(.menu_item, .{ .text = "Cut", .icon = "edit", .on_press = .cut_selection }, .{}),
ui.el(.menu_item, .{ .text = "Copy", .icon = "copy", .on_press = .copy_selection }, .{}),
ui.el(.menu_item, .{ .text = "Paste", .icon = "file-text", .disabled = true }, .{}),
ui.separator(.{}),
ui.el(.menu_item, .{ .text = "Select All", .on_press = .select_all }, .{}),
})
```
Right-click context menus are a separate channel with its own element: a `<context-menu>` child on a pressable element (or `ElementOptions.context_menu` in Zig views) presents the platform's native menu — macOS `NSMenu` — and falls back to an anchored surface on hosts without one; selections dispatch typed Msgs. The anchored `dropdown-menu` here is for app-designed surfaces you open yourself (pickers, button dropdowns, hold-reveal menus) — see [Menus](/menus).
## Attributes
Dropdown-menu:
<AttrTable element="dropdown-menu" attrs={["anchor", "anchor-alignment", "anchor-offset", "on-dismiss", "min-width"]} />
Menu-item:
<AttrTable attrs={["text", "icon", "disabled", "on-press"]} />
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/icon");
export default function IconLayout({ children }: { children: React.ReactNode }) {
return children;
}
+59
View File
@@ -0,0 +1,59 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { IconGallery } from "@/components/icon-gallery";
# Icon
A vector icon leaf: `name` selects the icon, tint comes from the `foreground` color token, and size from `width`/`height` (square by default). A bare literal name is one of the curated built-in stroke icons — the markup compiler and the Zig builder both validate it at comptime against the registry, so icon references never rot. The same grammar powers the inline `icon` attribute on [button](/components/button), toggle-button, list-item, menu-item, and badge, where the icon draws as part of the control's single hit target.
<ComponentPreview name="icon" alt="Built-in vector icons rendered by the engine" caption="a row of registry icons at the default size and tint" />
## Markup
```html
<row gap="20" cross="center">
<icon name="play"></icon>
<icon name="search" foreground="text_muted"></icon>
<icon name="git-branch" width="28" height="28"></icon>
<icon name="check-circle" foreground="success"></icon>
</row>
```
## App icons and bound names
Two more value forms open the vocabulary without weakening it:
- `app:<name>` draws an icon the app registered at boot with `canvas.icons.registerAppIcons` (comptime-parse the SVG with `canvas.svg_icon.parseComptime`). Declare the table as `pub const app_icons` on the app root and pass it to `registerAppIcons` in `main` — the model contract carries the same names, so `native check` verifies every `app:` reference against exactly what the app registers (with a did-you-mean). Without a fresh contract artifact the check degrades to structural checking and says so.
- `icon="{binding}"` defers the choice to model data — a field or function producing a built-in name or an `app:` name per row (a status icon per item, a play/pause toggle). The contract checks the binding resolves to a string.
Both forms resolve at draw time with an honest failure mode: a name that resolves nowhere renders the missing-icon fallback (a slashed circle) and logs a Debug warning naming the value — visible and loud, never a silent gap.
```html
<icon name="app:waveform" foreground="text_muted"></icon>
<button icon="{playPauseIcon}" on-press="toggle_play" label="Play or pause"></button>
```
## Registry
The full built-in set (`canvas.icons.known_icon_names`), rendered by the engine.
<IconGallery />
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. `ui.icon` takes the name as a comptime argument — an unknown name is a compile error pointing at `canvas.icons.known_icon_names`.
```zig
ui.row(.{ .gap = 20, .cross = .center }, .{
ui.icon(.{}, "play"),
ui.icon(.{ .style_tokens = .{ .foreground = .text_muted } }, "search"),
ui.icon(.{ .width = 28, .height = 28 }, "git-branch"),
ui.icon(.{ .style_tokens = .{ .foreground = .success } }, "check-circle"),
})
```
## Attributes
`name` is the icon element's own required attribute: a comptime-validated built-in name, an `app:<name>` reference to a registered app icon, or one `{binding}` resolving to such a name (not part of the shared attribute vocabulary). The rest are the shared sizing and tint attributes:
<AttrTable attrs={["width", "height", "foreground", "label"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/input-group");
export default function InputGroupLayout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,66 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Input Group
The composer shape: one bordered field wrapping a [textarea](/components/textarea) plus an accessory row of controls inside the same border — attach on the bottom-left, send on the bottom-right. The group wears the focus ring whenever focus is on any control inside it, and the textarea's own chrome dissolves automatically, so the whole group reads as a single field. The textarea keeps its full behavior: `text` and `placeholder` bind from the model, `on-input` hears every edit, `on-submit` rides the primary chord, and `autofocus` lands the keyboard on mount.
<ComponentPreview name="input-group" alt="An input group rendered by the engine" />
## Markup
The textarea comes first (document order is focus order), then the optional `input-group-actions` row. Put a `spacer` between the leading and trailing controls; `if`/`else` inside the row swap send for stop while a run is streaming.
```html
<input-group label="Message composer" height="120">
<textarea placeholder="Type a message" text="{draft}" on-input="draft_edited" on-submit="send" />
<input-group-actions>
<button icon="plus" variant="ghost" size="icon" on-press="attach" label="Attach"></button>
<spacer grow="1" />
<button icon="send" size="icon" on-press="send" label="Send"></button>
</input-group-actions>
</input-group>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.inputGroup(.{
.height = 120,
.semantics = .{ .label = "Message composer" },
}, ui.el(.textarea, .{
.placeholder = "Type a message",
.text = model.draft,
.on_input = Ui.inputMsg(.draft_edited),
.on_submit = .send,
.semantics = .{ .label = "Message" },
}, .{}), ui.inputGroupActions(.{}, .{
ui.el(.button, .{ .icon = "plus", .variant = .ghost, .size = .icon, .on_press = .attach, .semantics = .{ .label = "Attach" } }, .{}),
ui.spacer(1),
ui.el(.button, .{ .icon = "send", .size = .icon, .on_press = .send, .semantics = .{ .label = "Send" } }, .{}),
}))
```
## Attributes
<AttrTable
element="input-group"
attrs={[
"label",
"width",
"height",
"min-width",
"grow",
]}
/>
### input-group-actions
<AttrTable
element="input-group-actions"
attrs={[
"gap",
]}
/>
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/input");
export default function InputLayout({ children }: { children: React.ReactNode }) {
return children;
}
+97
View File
@@ -0,0 +1,97 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { CodeToggle } from "@/components/code-toggle";
# Input
Single-line text entry. `input` and `text-field` are the same foundation under two names: `text` and `placeholder` bind from the model, `on-input` names a Msg variant that receives every edit as a text-input event (the `TextInputEvent` union from `@native-sdk/core/text` in a TypeScript core; `canvas.TextInputEvent` in a Zig core), and `on-submit` dispatches on enter. `search-field` is the same control styled for search.
<ComponentPreview name="input" alt="Text inputs rendered by the engine" caption="placeholder, bound text, and disabled states" />
## Markup
```html
<column gap="12" width="280">
<input placeholder="Email address" text="{email}" on-input="email_edited" on-submit="subscribe" />
<text-field placeholder="Project name" text="{project_name}" on-input="name_edited" />
<input placeholder="Disabled" disabled="true" />
</column>
```
The core side is a text field the control renders and one arm reducing each edit over it — [Native UI § Messages](/native-ui#messages) walks the whole contract:
<CodeToggle>
```ts
import { type TextInputEvent } from "@native-sdk/core/text";
// the same module ships the full byte-splice reducer (applyTextInputEvent)
// for update to fold each edit over the model's draft bytes
export type Msg =
| { readonly kind: "subscribe" }
| { readonly kind: "email_edited"; readonly edit: TextInputEvent };
```
```zig
email_buffer: canvas.TextBuffer(128) = .{},
pub fn email(model: *const Model) []const u8 {
return model.email_buffer.text();
}
// in update:
.email_edited => |edit| model.email_buffer.apply(edit),
```
</CodeToggle>
## Search field
`search-field` renders the search affordance but binds exactly like an input; pair it with a model-filtered list. Whenever the field holds text it also shows a built-in clear affordance — a small x inside its trailing edge — and pressing it (or pressing Escape while focused) clears through the standard text-edit path, so the `on-input` handler receives the clear like any other edit and a model-owned buffer empties with it. No attribute enables or disables this; searchable fields simply carry it. For text entry that opens a menu of suggestions, see [combobox](/components/combobox).
<ComponentPreview name="search-field" alt="A search field rendered by the engine" />
```html
<search-field width="280" placeholder="Search notes" text="{query}" on-input="query_edited" />
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. `on_input` takes a comptime message constructor: `Ui.inputMsg(.tag)` builds `Msg{ .tag = edit }` for each `canvas.TextInputEvent`.
```zig
ui.column(.{ .gap = 12, .width = 280 }, .{
ui.el(.input, .{
.placeholder = "Email address",
.text = model.email,
.on_input = Ui.inputMsg(.email_edited),
.on_submit = .subscribe,
}, .{}),
ui.textField(.{ .placeholder = "Project name", .text = model.project_name, .on_input = Ui.inputMsg(.name_edited) }),
ui.el(.input, .{ .placeholder = "Disabled", .disabled = true }, .{}),
})
```
The search field builds the same way:
```zig
ui.el(.search_field, .{
.width = 280,
.placeholder = "Search notes",
.text = model.query,
.on_input = Ui.inputMsg(.query_edited),
}, .{})
```
## Attributes
<AttrTable
attrs={[
"text",
"placeholder",
"value",
"disabled",
"autofocus",
"on-input",
"on-submit",
"on-change",
]}
/>
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components");
export default function ComponentsLayout({ children }: { children: React.ReactNode }) {
return children;
}
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/list");
export default function ListLayout({ children }: { children: React.ReactNode }) {
return children;
}
+71
View File
@@ -0,0 +1,71 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# List
A vertical stack of items. The rows are `list-item` text leaves — the label is the content, `icon` draws a leading vector icon, `selected` marks the current row (usually an equality), `disabled` grays a row out, and `on-press` dispatches the row's Msg. For long data, `virtualized` with a fixed `virtual-item-extent` lays out only the visible window.
<ComponentPreview name="list" alt="A list with icons, a selected row, and a disabled row rendered by the engine" caption="icon rows with one selected and one disabled item" />
## Markup
```html
<list width="340" gap="2">
<list-item icon="file-text" selected="{doc == report}" on-press="open_report">Quarterly report.md</list-item>
<list-item icon="file-text" selected="{doc == checklist}" on-press="open_checklist">Launch checklist.md</list-item>
<list-item icon="folder" on-press="open_archive">Archive</list-item>
<list-item icon="music" disabled="true">demo-track.wav</list-item>
</list>
```
## Selection and activation
When selection is model state — the accent row your app owns — rows use the desktop gesture split: a single click selects (`on-press`), and the primary action (open the record, play the track) rides the double click and Enter. Bind the primary action twice — `on_double_press` for the pointer (Zig builder only; markup has no double-click event) and `on-submit` for the keyboard:
```zig
ui.listItem(.{
.selected = model.selected == track.id,
.on_press = Msg{ .select_track = track.id }, // click, and Space on a ring-focused row
.on_double_press = Msg{ .play_track = track.id }, // double click: the primary action
.on_submit = Msg{ .play_track = track.id }, // plain Enter: the same primary action
}, track.title)
```
The double click is additive, never a delay: the first click dispatches the select on its own release, the second release dispatches the play — select-then-act, with no press timer. On the keyboard, a bound `on-submit` makes plain Enter the row's primary action while Space keeps select; rows without one resolve Enter as select, unchanged.
The arrows stay app-owned after clicking around: pointer focus on a plain list row is quiet (no ring), and a quietly focused row routes no keys — arrows and Enter fall through to the app-level key fallback, where a selection-owning app moves its own selection. Tab onto a row draws the ring and restores the row's full keymap (arrows walk the rows, Space selects, Enter submits), and rows carrying `role="treeitem"` keep the [tree](/components/tree)'s roving keymap under either register. The full routing order is in [Native UI § Keyboard routing](/native-ui#keyboard-routing-focus-registers-quiet-list-rows-and-the-app-level-fallback); `examples/soundboard`'s track lists are the live reference for the whole pattern.
## Virtualization
For long row sets, turn on `virtualized` and give each row a fixed `virtual-item-extent` — the engine lays out only the rows in view. Loop the model data with a keyed `for` and dispatch a payload per row. The rows still all BUILD (this bounds layout and paint, not the tree), so it suits row sets the model already holds — hundreds, not hundreds of thousands. For dataset-scale rows where the view should only ever build the visible window, use the builder's [virtual list](/components/virtual-list).
```html
<list virtualized="true" virtual-item-extent="28" grow="1">
<for each="rows" as="row" key="id">
<list-item on-press="open_row:{row.id}">{row.title}</list-item>
</for>
</list>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.list(.{ .width = 340, .gap = 2 }, .{
ui.listItem(.{ .icon = "file-text", .selected = model.doc == .report, .on_press = .open_report }, "Quarterly report.md"),
ui.listItem(.{ .icon = "file-text", .selected = model.doc == .checklist, .on_press = .open_checklist }, "Launch checklist.md"),
ui.listItem(.{ .icon = "folder", .on_press = .open_archive }, "Archive"),
ui.listItem(.{ .icon = "music", .disabled = true }, "demo-track.wav"),
})
```
## Attributes
List:
<AttrTable attrs={["virtualized", "virtual-item-extent", "gap"]} />
List-item:
<AttrTable attrs={["text", "icon", "selected", "disabled", "on-press", "on-submit"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/markdown");
export default function MarkdownLayout({ children }: { children: React.ReactNode }) {
return children;
}
+31
View File
@@ -0,0 +1,31 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Markdown
Renders a markdown string (a GFM subset, pipe tables included) as native widgets through the same text pipeline as every other component — deterministic layout, selectable text. `source` is required and must be one `{binding}`; the element takes no children. Links dispatch `on-link` with the URL as payload (bare URLs autolink), `<details>` blocks toggle through `on-details` plus a model-owned `details-expanded` flag list, and `#123` references linkify through `issue-link-base`.
<ComponentPreview name="markdown" alt="A markdown document rendered by the engine" caption="headings, emphasis, inline code, lists, links, and a code block" />
## Markup
```html
<markdown source="{release_notes}" on-link="open_link" issue-link-base="https://github.com/native-sdk/native/issues/"></markdown>
```
## Programmatic construction (Zig)
The builder is the `canvas.markdown` module, parameterized over the app's Msg type; `on_link` pairs with `Ui.linkMsg(.tag)`.
```zig
const Md = native_sdk.canvas.markdown.Markdown(Msg);
Md.view(ui, model.release_notes, .{
.on_link = Ui.linkMsg(.open_link),
.issue_link_base = "https://github.com/native-sdk/native/issues/",
})
```
## Attributes
<AttrTable element="markdown" attrs={["source", "on-link", "on-details", "details-expanded", "issue-link-base"]} />
+9
View File
@@ -0,0 +1,9 @@
import { ComponentIndexGrid } from "@/components/component-index-grid";
# Components
The Native SDK ships a built-in component catalog with house-style defaults: neutral surfaces, sharp Geist typography, subtle borders, focus states, compact density, and token-driven color, radius, shadow, and motion. Every preview below is rendered by the engine itself — the deterministic reference renderer draws the same widgets your app ships, pixel for pixel, in both color schemes. Each card shows one representative rendering; the component's page has the full set of variations.
Native markup is the view language: every page leads with the component's element — its tags, attributes, and bindings, sourced from the markup validator's vocabulary. Where a sample dispatches into app logic, the core side appears in both authoring languages, TypeScript first with the Zig form one tab away. Each page closes with the component's programmatic construction — the `canvas.Ui` builder, for Zig views built without markup. For layout semantics, expressions, and the runtime contract, see [Native UI](/native-ui).
<ComponentIndexGrid />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/pagination");
export default function PaginationLayout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,42 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Pagination
A row container for page navigation — children flow horizontally and are ordinary [buttons](/components/button): `ghost` page numbers with the current page carrying the `outline` variant and `selected`, `chevron-left`/`chevron-right` buttons for previous/next (`icon-placement="trailing"` puts the next chevron after its label), and an `ellipsis` [icon](/components/icon) standing in for a collapsed range. Each button dispatches its own Msg through `on-press`; the model owns the current page.
<ComponentPreview name="pagination" alt="A pagination row rendered by the engine" caption="previous/next chevrons around ghost page buttons, page 1 selected" />
## Markup
```html
<pagination>
<button variant="ghost" icon="chevron-left" on-press="prev_page">Previous</button>
<for each="pages" as="p" key="number">
<button variant="{p.variant}" selected="{p.number == page}" on-press="open_page:{p.number}">{p.label}</button>
</for>
<icon name="ellipsis" />
<button variant="ghost" icon="chevron-right" icon-placement="trailing" on-press="next_page">Next</button>
</pagination>
```
The current page is the `outline` one among `ghost` siblings — the variant carries the currency, `selected` carries the semantics. In a `for` loop, compute the variant name on the row (`p.variant` returning `outline` or `ghost`).
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.el(.pagination, .{}, .{
ui.button(.{ .variant = .ghost, .icon = "chevron-left", .on_press = .prev_page }, "Previous"),
ui.button(.{ .variant = .outline, .selected = model.page == 1, .on_press = .{ .open_page = 1 } }, "1"),
ui.button(.{ .variant = .ghost, .on_press = .{ .open_page = 2 } }, "2"),
ui.button(.{ .variant = .ghost, .on_press = .{ .open_page = 3 } }, "3"),
ui.icon(.{}, "ellipsis"),
ui.button(.{ .variant = .ghost, .icon = "chevron-right", .icon_placement = .trailing, .on_press = .next_page }, "Next"),
})
```
## Attributes
<AttrTable attrs={["selected", "on-press", "icon", "icon-placement"]} />
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/panel");
export default function PanelLayout({ children }: { children: React.ReactNode }) {
return children;
}
+42
View File
@@ -0,0 +1,42 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Panel
`panel` is the plain surface container: background, border, radius — the workhorse surface for sidebars, wells, and status areas. Like [card](/components/card) it is a stacking container — children layer on top of each other and `gap` is rejected — so put a single `column` (or `row`) inside for flow. Binding `on-press` makes the whole surface pressable. See [Native UI](/native-ui) for how surfaces pick up color tokens.
<ComponentPreview name="panel" alt="A panel rendered by the engine" />
## Markup
```html
<panel width="340" padding="16">
<column gap="6">
<text>Build output</text>
<text wrap="true" foreground="text_muted">Warm cache, 214 modules, 1.8s.</text>
</column>
</panel>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.panel(.{ .width = 340, .padding = 16 }, .{
ui.column(.{ .gap = 6 }, .{
ui.text(.{}, "Build output"),
ui.text(.{ .wrap = true, .style_tokens = .{ .foreground = .text_muted } }, "Warm cache, 214 modules, 1.8s."),
}),
})
```
## Attributes
<AttrTable
attrs={[
"padding",
"width",
"on-press",
]}
/>
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/progress");
export default function ProgressLayout({ children }: { children: React.ReactNode }) {
return children;
}
+31
View File
@@ -0,0 +1,31 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Progress
A display-only value control: bind `value` as a 0..1 fraction and the engine fills the bar — no events, no interaction. A `value` outside 0..1 is clamped at render, never an error. Give it a definite `width` (or a `grow`) to size the track. For a user-adjustable value use [slider](/components/slider); for indeterminate waits use [spinner](/components/spinner).
<ComponentPreview name="progress" alt="A progress bar rendered by the engine" />
## Markup
```html
<progress value="{upload_fraction}" width="280" />
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.el(.progress, .{ .value = model.upload_fraction, .width = 280 }, .{})
```
## Attributes
<AttrTable
attrs={[
"value",
"width",
]}
/>
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/radio");
export default function RadioLayout({ children }: { children: React.ReactNode }) {
return children;
}
+59
View File
@@ -0,0 +1,59 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { CodeToggle } from "@/components/code-toggle";
# Radio
The single-choice value control, grouped by a `radio-group` row container. Like [checkbox](/components/checkbox), the label rides the `text` attribute — radio is not a text-bearing element, so text content between the tags is rejected with a teaching error. One model field holds the group's selection: render it with `{a == b}` equalities on each radio's `checked`, and let each radio's `on-toggle` dispatch the Msg that sets the field — the engine never flips state on its own.
<ComponentPreview name="radio-group" alt="A radio group rendered by the engine" caption="a radio group with one selected and one disabled option" />
## Markup
```html
<radio-group gap="12">
<radio checked="{density == default}" on-toggle="set_default" text="Default" />
<radio checked="{density == comfortable}" on-toggle="set_comfortable" text="Comfortable" />
<radio checked="{density == compact}" disabled="true" text="Compact" />
</radio-group>
```
One field holds the group's choice — a string-literal union in a TypeScript core, an enum in a Zig core — and each radio's arm sets it:
<CodeToggle>
```ts
// model: { readonly density: "default" | "comfortable" | "compact" }
case "set_comfortable":
return { ...model, density: "comfortable" };
```
```zig
// model: density: enum { default, comfortable, compact } = .default,
.set_comfortable => model.density = .comfortable,
```
</CodeToggle>
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.el(.radio_group, .{ .gap = 12 }, .{
ui.el(.radio, .{ .text = "Default", .checked = model.density == .default, .on_toggle = .set_default }, .{}),
ui.el(.radio, .{ .text = "Comfortable", .checked = model.density == .comfortable, .on_toggle = .set_comfortable }, .{}),
ui.el(.radio, .{ .text = "Compact", .checked = model.density == .compact, .disabled = true }, .{}),
})
```
## Attributes
<AttrTable
attrs={[
"text",
"checked",
"disabled",
"on-toggle",
]}
/>
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/resizable");
export default function ResizableLayout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,38 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Resizable
A single panel with an engine-managed drag handle: `width` sets the initial width and the engine owns the drag from there — no fraction bookkeeping in the model. The drag resizes only the panel itself; neighboring widgets keep their frames. It is a stacking surface — no `gap`; put a row or column inside for flow. When the model should own the pane fraction, or two panes should reflow together as one divider moves, use [split](/components/split) instead.
<ComponentPreview name="resizable" alt="A resizable sidebar panel rendered by the engine" caption="a resizable panel — drag its right edge to resize it" />
## Markup
```html
<row grow="1">
<resizable width="260">
<column padding="12">
<text foreground="text_muted">Sidebar</text>
</column>
</resizable>
</row>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.row(.{ .grow = 1 }, .{
ui.el(.resizable, .{ .width = 260 }, .{
ui.column(.{ .padding = 12 }, .{
ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Sidebar"),
}),
}),
})
```
## Attributes
<AttrTable attrs={["width", "min-width"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/scroll");
export default function ScrollLayout({ children }: { children: React.ReactNode }) {
return children;
}
+65
View File
@@ -0,0 +1,65 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { CodeToggle } from "@/components/code-toggle";
# Scroll
A scroll view: wrap multiple children in a single column inside it. The engine owns wheel, kinetic, and keyboard scrolling and draws the scrollbar while a scroll is in flight; `on-scroll` names a Msg variant with a `canvas.ScrollState` payload — or, in a transpiled TypeScript core, a declared record of the same `offset`/`velocity`/`viewport_extent`/`content_extent` fields, matched by name — that delivers the post-scroll offset and viewport/content extents, so the model can observe position without owning it. Echo the offset into a model field bound as `value` and the model owns the position too: setting the field scrolls the region (the controlled-scroll shape). Scrolling pins at the content edges by default — no rubber-band bounce; kinetic motion stops cleanly at the boundary. `overscroll="rubber_band"` opts one region into bouncing past its edges (both the engine physics and the native macOS scroller honor it), and the `ScrollPhysics.overscroll` design token flips the app-wide default, which per-region values override. `on-reach-end` dispatches a plain Msg when a scroll comes within one viewport of the content end — the infinite-fetch signal, fired once per approach with hysteresis (appending a batch grows the extent and re-arms the next approach). A programmatic jump to the end fires once and never re-arms while the offset stays near the end — re-arming needs a post-scroll observation at least 1.5 viewports from it. Pair with [list](/components/list) for layout-culled rows, or the builder's [virtual list](/components/virtual-list) for dataset-scale windows.
<ComponentPreview name="scroll" alt="A scroll region rendered by the engine" caption="a fixed-height scroll region; the engine draws the scrollbar during scrolling" />
## Markup
```html
<scroll height="240" padding="8" on-scroll="log_scrolled">
<column gap="2">
<list-item on-press="open_entry">Changelog entry 14</list-item>
<list-item on-press="open_entry">Changelog entry 13</list-item>
<list-item on-press="open_entry">Changelog entry 12</list-item>
<list-item on-press="open_entry">Changelog entry 11</list-item>
<list-item on-press="open_entry">Changelog entry 10</list-item>
<list-item on-press="open_entry">Changelog entry 9</list-item>
<list-item on-press="open_entry">Changelog entry 8</list-item>
</column>
</scroll>
```
The arm receives the scroll state and stores what the model wants to remember — echo `offset` into a field bound as the scroll's `value` and the model owns the position too:
<CodeToggle>
```ts
import { type ScrollState } from "@native-sdk/core/events";
export type Msg = /* ... */ | { readonly kind: "log_scrolled"; readonly scroll: ScrollState };
// in update:
case "log_scrolled":
return { ...model, changelog_offset: msg.scroll.offset };
```
```zig
// Msg arm: log_scrolled: canvas.ScrollState
.log_scrolled => |scroll| model.changelog_offset = scroll.offset,
```
</CodeToggle>
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. `on_scroll` pairs with `Ui.scrollMsg(.tag)`; the delivered offset is the value the runtime already applied, so echoing it back never fights the scroll reconcile.
```zig
ui.scroll(.{ .height = 240, .padding = 8, .on_scroll = Ui.scrollMsg(.log_scrolled) }, .{
ui.column(.{ .gap = 2 }, .{
ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 14"),
ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 13"),
ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 12"),
ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 11"),
ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 10"),
}),
})
```
## Attributes
<AttrTable attrs={["on-scroll", "on-reach-end", "overscroll", "height", "width", "padding"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/select");
export default function SelectLayout({ children }: { children: React.ReactNode }) {
return children;
}
+88
View File
@@ -0,0 +1,88 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { CodeToggle } from "@/components/code-toggle";
# Select
`select` is a trigger-only primitive — it takes no options attribute. Its content is the current value (`placeholder` shows while empty) and `on-press` opens. The options are composed as an anchored [dropdown-menu](/components/dropdown-menu) of menu-items beside the trigger in a `stack`, rendered under an `if` — the model owns the open flag, and `on-dismiss` clears it when Escape, Tab, or a click outside closes the surface. In the open menu the `selected` option wears a trailing checkmark, and the row under the keyboard or pointer carries a full-row highlight — the two are independent, so arrowing away from the committed option never moves its mark. For a trigger the user types into, see [combobox](/components/combobox).
<ComponentPreview name="select" alt="Select triggers rendered by the engine" caption="a select trigger with a value, and a disabled trigger — press to open the anchored menu" />
## Markup
The full pattern: trigger and menu are siblings in a `stack`, the menu floats with `anchor="below"` (`anchor-alignment="stretch"` widens it to the trigger, the select-menu look), and every path back to closed goes through the model — picking dispatches `pick_env` and `on-dismiss` dispatches `close_env_picker`, both of which clear `env_picker_open` in `update`.
The keyboard rides the same composition with no extra wiring: on the focused trigger, Enter, Space, or an arrow key presses it (the model-owned open); with the menu mounted, Down/Up move into and walk the options (entering at the `selected` one) and Home/End jump to the edges — walking only moves the highlight, it never dispatches a pick. Enter or a click commits the highlighted option (one `on-press`, then the model closes); Escape, Tab, or a click outside dismisses through `on-dismiss` with the committed value untouched — focus returns to the trigger either way.
```html
<stack width="240">
<select placeholder="Choose environment" on-press="toggle_env_picker">{envName}</select>
<if test="{env_picker_open}">
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_env_picker">
<for each="environments" key="id" as="e">
<menu-item selected="{e.id == env_id}" on-press="pick_env:{e.id}">{e.name}</menu-item>
</for>
</dropdown-menu>
</if>
</stack>
```
The core side is the open flag, the committed choice, and three arms:
<CodeToggle>
```ts
// model: { readonly env_id: number; readonly env_picker_open: boolean }
case "toggle_env_picker":
return { ...model, env_picker_open: !model.env_picker_open };
case "close_env_picker":
return { ...model, env_picker_open: false };
case "pick_env":
return { ...model, env_id: msg.id, env_picker_open: false };
```
```zig
.toggle_env_picker => model.env_picker_open = !model.env_picker_open,
.close_env_picker => model.env_picker_open = false,
.pick_env => |id| {
model.env_id = id;
model.env_picker_open = false;
},
```
</CodeToggle>
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.stack(.{ .width = 240 }, .{
ui.el(.select, .{
.text = model.envName(),
.placeholder = "Choose environment",
.on_press = .toggle_env_picker,
}, .{}),
if (model.env_picker_open) ui.el(.dropdown_menu, .{
.anchor = .below,
.anchor_alignment = .stretch,
.on_dismiss = .close_env_picker,
}, .{
ui.el(.menu_item, .{ .text = "Production", .selected = model.env_id == 0, .on_press = .{ .pick_env = 0 } }, .{}),
ui.el(.menu_item, .{ .text = "Staging", .selected = model.env_id == 1, .on_press = .{ .pick_env = 1 } }, .{}),
ui.el(.menu_item, .{ .text = "Development", .selected = model.env_id == 2, .on_press = .{ .pick_env = 2 } }, .{}),
}) else ui.spacer(0),
})
```
## Attributes
<AttrTable
attrs={[
"text",
"placeholder",
"disabled",
"on-press",
"on-dismiss",
]}
/>
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/separator");
export default function SeparatorLayout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,48 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Separator
A layout leaf that draws a line: a horizontal rule in a column, a thin vertical divider in a row (give the vertical form an explicit `width` and `height`). For flexible empty space between siblings, see [spacer](/components/spacer).
<ComponentPreview name="separator" alt="Separators rendered by the engine" caption="a horizontal rule in a column and thin vertical dividers in a row" />
## Markup
```html
<column gap="12" width="300">
<text>Native SDK</text>
<text foreground="text_muted">A component catalog rendered by the engine.</text>
<separator></separator>
<row gap="12" cross="center" height="20">
<text>Docs</text>
<separator width="1" height="16"></separator>
<text>Source</text>
<separator width="1" height="16"></separator>
<text>Changelog</text>
</row>
</column>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.column(.{ .gap = 12, .width = 300 }, .{
ui.text(.{}, "Native SDK"),
ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "A component catalog rendered by the engine."),
ui.separator(.{}),
ui.row(.{ .gap = 12, .cross = .center, .height = 20 }, .{
ui.text(.{}, "Docs"),
ui.separator(.{ .width = 1, .height = 16 }),
ui.text(.{}, "Source"),
ui.separator(.{ .width = 1, .height = 16 }),
ui.text(.{}, "Changelog"),
}),
})
```
## Attributes
<AttrTable attrs={["width", "height"]} />
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/sheet");
export default function SheetLayout({ children }: { children: React.ReactNode }) {
return children;
}
+51
View File
@@ -0,0 +1,51 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Sheet
A bottom-anchored surface with the same contract as [dialog](/components/dialog) and [drawer](/components/drawer): rendered in place, title via the `text` attribute, visibility model-owned behind an `if`, and `on-dismiss` dispatched on Escape or click-outside so `update` clears the open flag. The sheet rises from the bottom edge; size it with `height`. The title chrome is engine-drawn and children stack over the full content box, so lead the body column with a fixed-height spacer.
<ComponentPreview name="sheet" alt="A sheet surface rendered by the engine" caption="a bottom sheet with a title, body text, and an action row" />
## Markup
```html
<column padding="24">
<button variant="outline" icon="external-link" on-press="open_share">Share</button>
<if test="{share_open}">
<sheet text="Share" height="190" padding="24" on-dismiss="close_share">
<column gap="12">
<spacer height="34"></spacer>
<text wrap="true" foreground="text_muted">Anyone with the link can view this board.</text>
<row gap="8">
<input text="{share_url}" label="Share link" grow="1"></input>
<button variant="secondary" icon="copy" on-press="copy_link">Copy</button>
</row>
</column>
</sheet>
</if>
</column>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.el(.sheet, .{ .text = "Share", .height = 190, .padding = 24, .on_dismiss = .close_share }, .{
ui.column(.{ .gap = 12 }, .{
ui.column(.{ .height = 34 }, .{}), // clears the engine-drawn title line
ui.text(.{ .wrap = true, .style_tokens = .{ .foreground = .text_muted } }, "Anyone with the link can view this board."),
ui.row(.{ .gap = 8 }, .{
ui.el(.input, .{ .text = model.share_url, .grow = 1, .semantics = .{ .label = "Share link" } }, .{}),
ui.button(.{ .variant = .secondary, .icon = "copy", .on_press = .copy_link }, "Copy"),
}),
}),
})
```
## Attributes
The sheet rejects `gap` — it layers children like the other stacking surfaces; put a column (or row) inside for flow.
<AttrTable attrs={["text", "width", "height", "padding", "on-dismiss"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/skeleton");
export default function SkeletonLayout({ children }: { children: React.ReactNode }) {
return children;
}
+38
View File
@@ -0,0 +1,38 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Skeleton
Loading states. The skeleton is a placeholder block — size it with `width` and `height` to sketch the shape of the content it stands in for, and swap it for the real widgets (behind an `if`) when the data lands. While mounted it pulses gently (an engine-owned opacity oscillation, like the spinner's spin — no wiring, and reduce-motion renders it static). The spinner is an indeterminate progress leaf for work with no measurable fraction; when there is one, use [progress](/components/progress) instead.
<ComponentPreview name="skeleton" alt="Skeleton placeholders rendered by the engine" caption="an avatar-and-two-lines skeleton, the shape of the loaded row" />
## Markup
```html
<row gap="12" width="320">
<skeleton width="44" height="44"></skeleton>
<column gap="8" grow="1" main="center">
<skeleton height="14"></skeleton>
<skeleton height="14" width="180"></skeleton>
</column>
</row>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. Skeleton has no sugar method; use `ui.el` with the element kind.
```zig
ui.row(.{ .gap = 12, .width = 320 }, .{
ui.el(.skeleton, .{ .width = 44, .height = 44 }, .{}),
ui.column(.{ .gap = 8, .grow = 1, .main = .center }, .{
ui.el(.skeleton, .{ .height = 14 }, .{}),
ui.el(.skeleton, .{ .height = 14, .width = 180 }, .{}),
}),
})
```
## Attributes
<AttrTable attrs={["width", "height"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/slider");
export default function SliderLayout({ children }: { children: React.ReactNode }) {
return children;
}
+61
View File
@@ -0,0 +1,61 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { CodeToggle } from "@/components/code-toggle";
# Slider
A draggable value control. The model owns `value` as a 0..1 fraction and renders it back on every rebuild; `on-change` dispatches when the user moves the thumb (drag, keyboard step, or accessibility set-value). In markup, `on-change` resolves by the named Msg arm's shape: a bare tag naming a VALUE arm — `f32`, or a transpiled core's one-number float arm — dispatches the applied fraction as its payload (the seek-bar shape), while a bare tag naming a void arm stays the plain "something changed" signal. Either way, `update` echoes the delivered value back into the model field. Reconcile follows the scroll rule: a source-side move wins (a model-driven value — playback progress on a seek bar — renders every rebuild), a source replaying the same value keeps the user's drag, and a live drag is never yanked mid-gesture. For a display-only bar, use [progress](/components/progress).
<ComponentPreview name="slider" alt="Sliders rendered by the engine" caption="a bound slider and a disabled slider" />
## Markup
```html
<column gap="20" width="280">
<slider value="{volume}" on-change="volume_changed" label="Volume" />
<slider value="0.7" disabled="true" label="Balance" />
</column>
```
With a one-number float arm — `{ kind: "volume_changed"; fraction: number }` in a TypeScript core, `volume_changed: f32` in a Zig core — the arm receives the applied fraction directly (the `examples/soundboard-ts` seek bar is the live pattern):
<CodeToggle>
```ts
// in update: the value event delivers the applied fraction
case "volume_changed":
return { ...model, volume: msg.fraction };
```
```zig
// in update: the value event delivers the applied fraction
.volume_changed => |fraction| model.volume = fraction,
```
</CodeToggle>
A void `volume_changed` arm keeps the older plain form; the `Options.sync` hook remains available to Zig apps that mirror runtime widget state wholesale.
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically; `on_value = Ui.valueMsg(.tag)` is the value channel:
```zig
ui.column(.{ .gap = 20, .width = 280 }, .{
ui.el(.slider, .{
.value = model.volume,
.on_value = Ui.valueMsg(.volume_changed),
}, .{}),
ui.el(.slider, .{ .value = 0.7, .disabled = true, .semantics = .{ .label = "Balance" } }, .{}),
})
```
## Attributes
<AttrTable
attrs={[
"value",
"disabled",
"on-change",
]}
/>
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/spacer");
export default function SpacerLayout({ children }: { children: React.ReactNode }) {
return children;
}
+40
View File
@@ -0,0 +1,40 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Spacer
A layout leaf of flexible empty space between siblings — give it a `grow` and it soaks up the leftover main-axis room, which is how toolbars push actions to the far edge. A fixed `width` or `height` makes it a rigid gap instead (the fixed-height form is how modal bodies clear the engine-drawn title line). For a visible dividing line, see [separator](/components/separator).
<ComponentPreview name="spacer" alt="A spacer pushing a toolbar action to the far edge, rendered by the engine" caption="a grow spacer pushing the action button to the trailing edge" />
## Markup
```html
<panel width="340" padding="12">
<row gap="12" cross="center">
<text>Docs</text>
<text foreground="text_muted">Source</text>
<spacer grow="1"></spacer>
<button size="sm" variant="secondary" on-press="open_changelog">Changelog</button>
</row>
</panel>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. `ui.spacer(1)` is the builder shorthand for a grow-1 spacer.
```zig
ui.panel(.{ .width = 340, .padding = 12 }, .{
ui.row(.{ .gap = 12, .cross = .center }, .{
ui.text(.{}, "Docs"),
ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Source"),
ui.spacer(1),
ui.button(.{ .size = .sm, .variant = .secondary, .on_press = .open_changelog }, "Changelog"),
}),
})
```
## Attributes
<AttrTable attrs={["width", "height", "grow"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/spinner");
export default function SpinnerLayout({ children }: { children: React.ReactNode }) {
return children;
}
+32
View File
@@ -0,0 +1,32 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Spinner
An indeterminate progress leaf for work with no measurable fraction; when there is one, use [progress](/components/progress) instead. For a placeholder that sketches the shape of the loading content, use [skeleton](/components/skeleton).
<ComponentPreview name="spinner" alt="An indeterminate spinner rendered by the engine" />
## Markup
```html
<row gap="12" cross="center">
<spinner></spinner>
<text foreground="text_muted">Loading notes…</text>
</row>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. Spinner has no sugar method; use `ui.el` with the element kind.
```zig
ui.row(.{ .gap = 12, .cross = .center }, .{
ui.el(.spinner, .{}, .{}),
ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Loading notes…"),
})
```
## Attributes
<AttrTable attrs={["width", "height"]} />
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/split");
export default function SplitLayout({ children }: { children: React.ReactNode }) {
return children;
}
+80
View File
@@ -0,0 +1,80 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { CodeToggle } from "@/components/code-toggle";
# Split
`split` is a two-pane horizontal splitter with a draggable divider between exactly two element children. `value` binds the model-owned first-pane fraction, and `on-resize` names a Msg variant with an `f32` payload that delivers the applied fraction after every divider drag, keyboard adjustment, and assistive increment — echo it back into `value` in `update`, and the delivered fraction never fights the reconcile. `min-width` on the panes bounds the drag, `gap` sets the divider band thickness, and nesting splits gives you more panes. For a single panel with an engine-managed drag handle (no fraction bookkeeping), see [resizable](/components/resizable).
<ComponentPreview name="split" alt="A two-pane splitter rendered by the engine" caption="a sidebar/content split with a divider band" />
## Markup
```html
<split value="{sidebar_fraction}" on-resize="sidebar_resized" gap="8" grow="1">
<panel padding="12" min-width="180">
<text foreground="text_muted">Sidebar</text>
</panel>
<panel padding="12" min-width="320">
<text foreground="text_muted">Content</text>
</panel>
</split>
```
A `value` of 0 lays out at 0.5, so a fresh model starts centered. Put conditional or repeated content inside a pane container — the split itself takes exactly two children.
In `update`, the arm echoes the applied fraction back into the bound field:
<CodeToggle>
```ts
case "sidebar_resized":
return { ...model, sidebar_fraction: msg.fraction };
```
```zig
.sidebar_resized => |fraction| model.sidebar_fraction = fraction,
```
</CodeToggle>
## Animated resize
`resize-duration` (milliseconds) turns the bound `value` into a target: a rebuild that moves it — a collapse toggle in `update`, not a drag echo — eases the rendered fraction there instead of snapping, one step per presented frame, reflowing both panes exactly as a divider drag would and dispatching the same `on-resize` echoes. `resize-easing` names the curve (`linear`, `standard` — the default — `emphasized`, `spring`) and needs a nonzero duration beside it. Under a reduced-motion appearance the split snaps automatically — apps need do nothing. Omitting `resize-duration` (or setting 0) keeps today's snap.
```html
<split value="{sidebar_fraction}" resize-duration="180" resize-easing="standard" on-resize="sidebar_resized" gap="8" grow="1">
<panel padding="12" min-width="180">
<text foreground="text_muted">Sidebar</text>
</panel>
<panel padding="12" min-width="320">
<text foreground="text_muted">Content</text>
</panel>
</split>
```
Here `sidebar_fraction` is the model's declared resting fraction — flip it between the expanded and collapsed values in `update` and the runtime animates the divider there. In Zig views the same pair is `ElementOptions.resize_duration`/`resize_easing` on `ui.split`.
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. `on_resize` pairs with `valueMsg`, which builds the `f32`-payload message constructor.
```zig
ui.split(.{
.value = model.sidebar_fraction,
.on_resize = Ui.valueMsg(.sidebar_resized),
.gap = 8,
.grow = 1,
}, .{
ui.panel(.{ .padding = 12, .min_width = 180 }, .{
ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Sidebar"),
}),
ui.panel(.{ .padding = 12, .min_width = 320 }, .{
ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Content"),
}),
})
```
## Attributes
<AttrTable attrs={["value", "resize-duration", "resize-easing", "min-width", "gap", "on-resize"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/status-bar");
export default function StatusBarLayout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,36 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Status Bar
A status bar text leaf: the content is the status text (with `{}` interpolation), and it takes no children — the engine renders the bar's own text rather than laying out a child tree. Pin it as the last child of the window's root column so it hugs the bottom edge.
<ComponentPreview name="status-bar" alt="A status bar rendered by the engine" caption="a status bar pinned under the window content" />
## Markup
```html
<column grow="1">
<column grow="1" padding="32" main="center" cross="center">
<text foreground="text_muted">Window content</text>
</column>
<status-bar>Ready — 3 notes synced</status-bar>
</column>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. The builder models it as a text leaf: `ui.statusBar(options, text)`.
```zig
ui.column(.{ .grow = 1 }, .{
ui.column(.{ .grow = 1, .padding = 32, .main = .center, .cross = .center }, .{
ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Window content"),
}),
ui.statusBar(.{}, "Ready — 3 notes synced"),
})
```
## Attributes
<AttrTable attrs={["text", "text-alignment"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/stepper");
export default function StepperLayout({ children }: { children: React.ReactNode }) {
return children;
}
+39
View File
@@ -0,0 +1,39 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
import { EjectSection } from "@/components/eject-section";
# Stepper
A stage stepper: `step` children joined by hairline connectors, with `active` naming the current step index — earlier steps render completed (a check indicator), later ones pending, and an index past the last step renders every step completed. Display-only: driving `active` belongs to the app model. Step children are text leaves only (the label supports `{}` interpolation); their completed/active/pending state derives entirely from the stepper's `active` index. For a vertical run ledger, see [timeline](/components/timeline).
<ComponentPreview name="stepper" alt="A stage stepper rendered by the engine" caption="a completed step, the active step, and a pending step" />
## Markup
`active` is required — a number or one `{binding}`.
```html
<stepper active="{publish_step}">
<step>Draft</step>
<step>Review</step>
<step>Publish</step>
</stepper>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
```zig
ui.stepper(.{ .active = model.publish_step }, &.{
.{ .label = "Draft" },
.{ .label = "Review" },
.{ .label = "Publish" },
})
```
<EjectSection components={["stepper"]} />
## Attributes
<AttrTable element="stepper" attrs={["active", "key", "global-key", "label"]} />
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/switch");
export default function SwitchLayout({ children }: { children: React.ReactNode }) {
return children;
}
+41
View File
@@ -0,0 +1,41 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Switch
An on/off switch control. The label is the element's content, the model binds `checked`, and `on-toggle` dispatches its Msg — same contract as [checkbox](/components/checkbox), rendered as a sliding thumb.
<ComponentPreview name="switch" alt="Switches rendered by the engine" caption="on, off, and disabled switches" />
## Markup
```html
<column gap="12">
<switch checked="{airplane_mode}" on-toggle="toggle_airplane_mode">Airplane mode</switch>
<switch checked="{notifications}" on-toggle="toggle_notifications">Notifications</switch>
<switch checked="true" disabled="true">Managed setting</switch>
</column>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically — the widget kind is `.switch_control`:
```zig
ui.column(.{ .gap = 12 }, .{
ui.el(.switch_control, .{ .text = "Airplane mode", .checked = model.airplane_mode, .on_toggle = .toggle_airplane_mode }, .{}),
ui.el(.switch_control, .{ .text = "Notifications", .checked = model.notifications, .on_toggle = .toggle_notifications }, .{}),
ui.el(.switch_control, .{ .text = "Managed setting", .checked = true, .disabled = true }, .{}),
})
```
## Attributes
<AttrTable
attrs={[
"text",
"checked",
"disabled",
"on-toggle",
]}
/>
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/table");
export default function TableLayout({ children }: { children: React.ReactNode }) {
return children;
}
+57
View File
@@ -0,0 +1,57 @@
import { ComponentPreview } from "@/components/component-preview";
import { AttrTable } from "@/components/attr-table";
# Table
A vertical table container: children are `table-row` elements, and each row's children are `table-cell` text leaves. Cells only live in rows and rows only live in tables — the validator enforces the nesting. Column sizing is flex: give each cell a `grow` factor and the columns line up across rows. The chrome is engine-owned: hairline separators under every row but the last (no outer box, no cell gridlines), a full-width hover wash on rows, and `selected` highlights a row. Style the header row's cells muted and small, right-align numeric columns with `text-alignment="end"`, and cells dispatch `on-press`.
<ComponentPreview name="table" alt="An invoice table with a selected row rendered by the engine" caption="a header row, data rows, and one selected row" />
## Markup
```html
<table width="420">
<table-row>
<table-cell grow="1" size="sm" foreground="text_muted">Invoice</table-cell>
<table-cell grow="1" size="sm" foreground="text_muted">Status</table-cell>
<table-cell grow="1" size="sm" foreground="text_muted" text-alignment="end">Amount</table-cell>
</table-row>
<for each="invoices" as="invoice" key="id">
<table-row selected="{invoice.id == selected_invoice}">
<table-cell grow="1" on-press="open_invoice:{invoice.id}">{invoice.number}</table-cell>
<table-cell grow="1">{invoice.status}</table-cell>
<table-cell grow="1" text-alignment="end">{invoice.amount}</table-cell>
</table-row>
</for>
</table>
```
## Programmatic construction (Zig)
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. The builder names differ from the markup: rows are `.data_row` and cells are `.data_cell` (the underlying widget kinds).
```zig
ui.el(.table, .{ .width = 420 }, .{
ui.el(.data_row, .{}, .{
ui.el(.data_cell, .{ .text = "Invoice", .grow = 1, .size = .sm, .style_tokens = .{ .foreground = .text_muted } }, .{}),
ui.el(.data_cell, .{ .text = "Status", .grow = 1, .size = .sm, .style_tokens = .{ .foreground = .text_muted } }, .{}),
ui.el(.data_cell, .{ .text = "Amount", .grow = 1, .size = .sm, .text_alignment = .end, .style_tokens = .{ .foreground = .text_muted } }, .{}),
}),
ui.el(.data_row, .{}, .{
ui.el(.data_cell, .{ .text = "INV-001", .grow = 1, .on_press = .{ .open_invoice = 1 } }, .{}),
ui.el(.data_cell, .{ .text = "Paid", .grow = 1 }, .{}),
ui.el(.data_cell, .{ .text = "$250.00", .grow = 1, .text_alignment = .end }, .{}),
}),
ui.el(.data_row, .{ .selected = true }, .{
ui.el(.data_cell, .{ .text = "INV-002", .grow = 1, .on_press = .{ .open_invoice = 2 } }, .{}),
ui.el(.data_cell, .{ .text = "Pending", .grow = 1 }, .{}),
ui.el(.data_cell, .{ .text = "$150.00", .grow = 1, .text_alignment = .end }, .{}),
}),
})
```
For icon rows and single-column data prefer [list](/components/list); tables earn their keep once columns need to align.
## Attributes
<AttrTable attrs={["grow", "selected", "text-alignment", "on-press"]} />
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("components/tabs");
export default function TabsLayout({ children }: { children: React.ReactNode }) {
return children;
}

Some files were not shown because too many files have changed in this diff Show More