Imported from vercel-labs/native (
skill-data/native-ui/SKILL.md). Install upstream withnpx skills add vercel-labs/native --skill native-ui. Copyright stays with the author.
Author native UI with Native markup
A native-rendered Native SDK app is a markup view plus an app core. The primary authoring path is:
src/app.native— the entire UI: elements, layout, bindings, message dispatch.src/core.ts—Model, the discriminatedMsgunion,update(model, msg), effects, subscriptions, and derived binding helpers.app.zon— identity, windows, assets, permissions, and policy.
The TypeScript core is checked and compiled ahead of time to native code; no JS runtime ships in the binary. Load native skills get ts-core beside this skill for every default app implementation. A Zig core (src/main.zig) is first-class when explicitly selected with native init --template zig-core; the Zig-only wiring, builder, and runtime-extension recipes below are labeled so they are not mistaken for the default.
The markup compiles to the same widget tree a hand-written canvas.Ui(Msg) builder view would produce: identical structural widget ids, identical typed handler table. Markup can never mutate state — it binds values and dispatches messages; all logic lives in the app core, whichever core language the existing tree uses.
Editors highlight .native markup well in HTML mode — the default scaffold writes no editor config, so add .vscode/settings.json with "files.associations": {"*.native": "html"} yourself, or scaffold with native init --full, which writes it.
Start a new app with native init (zero-config: app.zon + src/core.ts + src/app.native + assets; the CLI generates the build graph). Use examples/chatbot, examples/soundboard, and examples/system-monitor as substantial TypeScript references. The native check|dev|test|build verbs drive any app directory shaped this way.
Menu-bar status items (TypeScript default)
Export statusItem(model: Model): StatusItemState from src/core.ts. The generated launcher installs icon/tooltip/click-open commands plus the boot presentation/menu, re-derives it after every committed update, and patches presentation/menu independently. Import StatusItemState from @native-sdk/core/events. Commands route through the core's commandMsg(name): Msg | null export. There is no app-owned Zig wiring and statusItem is automatically marked shell-bound for the markup lint.
import { asciiBytes, utf8Bytes } from "@native-sdk/core";
import { type StatusItemState } from "@native-sdk/core/events";
export function statusItem(model: Model): StatusItemState {
return {
iconPath: asciiBytes("assets/menu-bar.svg"),
tooltip: utf8Bytes("Player status"),
activationCommand: asciiBytes("app.refresh"),
alternateActivationCommand: asciiBytes("player.toggle"),
openCommand: asciiBytes("app.refresh"),
presentation: { title: model.playing ? utf8Bytes("MB PLAY") : utf8Bytes("MB"), width: 62, tone: "normal", iconOpacity: 1, monospaced: true, fontSize: 13, fontWeight: "semibold" },
items: [
{ id: 1, label: utf8Bytes("Open"), command: asciiBytes("app.open"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
{ id: 0, label: asciiBytes(""), command: asciiBytes(""), separator: true, enabled: false, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
{ id: 2, label: model.playing ? utf8Bytes("Pause") : utf8Bytes("Play"), command: asciiBytes("player.toggle"), separator: false, enabled: true, detail: utf8Bytes("configured ✓"), role: "agent", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
],
};
}
Presentation fields style the persistent menu-bar item. Inside its dropdown, metric carries prominent primary/secondary text, segmented carries up to eight typed command options, and chart carries 1–32 bounded values with captions and accessibility. These are generic composable row payloads, not text conventions.
App wiring (Zig cores and extensions only)
A default TypeScript app writes none of this: generated wiring connects src/core.ts, src/app.native, and app.zon. Use the following only when the existing app has a Zig core or the task explicitly requires owned runtime wiring.
const HabitsApp = 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 (avoid `App.init(alloc,
// model, ...)`: its by-value Model is a stack-overflow trap once the
// Model grows).
const app_state = try HabitsApp.create(std.heap.page_allocator, .{
.name = "habits",
.scene = shell_scene, // one window, one gpu_surface view
.canvas_label = "habits-canvas", // must match the ShellView label
.update = update,
.markup = .{
.source = @embedFile("app.native"),
.watch_path = "src/app.native", // dev hot reload; omit in release
.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. Tests that instantiate the app per fixture should use create/destroy too — a runtime-built Model passed to init by value crashes the test stack once models get large.)
The runtime owns the loop: install on first GPU frame, presentation, resize, pointer/keyboard dispatch into update + rebuild. With watch_path set, editing the .native file while the app runs hot-reloads the view within ~2s, preserving model state and widget ids; parse failures keep the last good view and set app_state.markup_diagnostic (line/column/message).
Release: compile the markup at comptime. canvas.CompiledMarkupView(Model, Msg, source).build parses the .native source entirely at compile time and produces the identical tree (same ids, handlers, dispatch) with no parser in the binary; markup or binding mistakes become compile errors with line/column. Hand it to .view, and gate the runtime engine per build mode:
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 = ..., .watch_path = "src/app.native", .io = init.io } else null,
With both set (dev), the compiled view renders until the watched file first changes, then the interpreter hot-reloads it. See the native reference fixture in tests/ts-core/habits_reference.zig for this Zig wiring pattern.
Webview panes: canvas + live web content in one window
For TypeScript apps, export webPanes(model: Model): readonly WebViewPane[] from core.ts. Import the descriptor from @native-sdk/core/events; return label, nullable anchor, url, x, y, width, height, and reloadToken. The label identifies an existing manifest WebView; the anchor identifies a widget label such as <panel label="preview-pane" grow="1"/>. See examples/canvas-preview for pane, frame, and tray coordination in one compiled core. The native adapter copies bytes and validates finite geometry and safe integer tokens; the OS owns WebView hosting and navigation policy.
For all u64 reload states, return readonly ExactWebViewPane[] instead. Its reloadToken is decimal ASCII bytes; the host validates the full u64 range without rounding. WebViewPane retains its existing safe-number token.
TypeScript terminal panes
Bind <terminal pty="{shellKey}" scrollback="{scrollback}" on-terminal="terminalChanged" label="Shell" autofocus="true" grow="1"/> to a Uint8Array helper or field naming the same key as Cmd.ptySpawn. Import TerminalState from @native-sdk/core/events for the message payload. The native host resolves the name to its PTY capability; engine identities never cross through a JavaScript number. Both view backends preserve the complete four-field state report. A bound slot remains reserved through exit until app teardown, keeping the ended screen stable across rebuilds. Restarting the same name reuses its slot; the existing four-slot budget applies. Keep .terminal_sessions = true and the lazy Ghostty pin in the app's owned build, as in examples/workbench. Native integer PTY bindings remain supported.
For Zig cores and extensions:
Declare the webview in the scene next to the gpu_surface (parent it to the canvas view), reserve its region with an empty panel carrying a semantics label, and let Options.web_panes snap the webview to that widget's layout frame while the model drives navigation:
const shell_views = [_]native_sdk.ShellView{
.{ .label = "app-canvas", .kind = .gpu_surface, .fill = true, .gpu_backend = .metal },
.{ .label = "preview", .kind = .webview, .parent = "app-canvas", .url = "https://example.com/", .x = 240, .y = 76, .width = 704, .height = 548 },
};
// view: ui.panel(.{ .grow = 1, .semantics = .{ .label = "preview-pane" } }, .{})
fn panes(model: *const Model, out: []App.WebViewPane) usize {
out[0] = .{ .label = "preview", .anchor = "preview-pane", .url = model.url(), .reload_token = model.reload_token };
return 1;
}
// options: .web_panes = panes,
URL changes navigate; bumping reload_token reloads the same URL (the CenterPane/Preview-tab shape). Pane URLs must pass security.navigation.allowed_origins. Panes reconcile against the runtime's live webview state on every rebuild and presented frame, so shell relayouts cannot detach them. examples/canvas-preview is the live reference; zig build test-canvas-preview-smoke verifies it.
Menu-bar extra (status item)
This is the Zig-core/custom-wiring form. Default TypeScript apps use the exported statusItem(model) helper above.
Options.status_item installs a macOS NSStatusItem once, on the installing frame; its menu items dispatch commands through the same on_command mapping the toolbar and menus use (source .tray):
.status_item = .{ .title = "ZN", .tooltip = "My App", .items = &.{
.{ .id = 1, .label = "Refresh", .command = "app.refresh" },
.{ .separator = true },
.{ .id = 2, .label = "Quit", .command = "app.quit" },
} },
For a LIVE menu-bar extra (an open-count badge in the title, a latest-items dropdown), add Options.status_item_fn — the web_panes pattern: consulted on install and after every rebuild, re-applied only when its output changed (title and menu patch independently; the static status_item keeps icon/tooltip). Format derived strings into the provided scratch; item commands dispatch through on_command exactly like static items:
fn statusItem(model: *const Model, scratch: *App.StatusItemScratch) App.StatusItemState {
const title = std.fmt.bufPrint(&scratch.title_buffer, "ZN {d}", .{model.open_count}) catch "ZN";
scratch.items[0] = .{ .id = 1, .label = "Refresh", .command = "app.refresh" };
var count: usize = 1;
for (model.latest(), 0..) |issue, i| { // per-row commands: map "issue.select.N" in on_command
scratch.items[count] = .{ .id = @intCast(10 + i), .label = issue.title, .command = issue.select_command };
count += 1;
}
return .{ .title = title, .items = scratch.items[0..count] };
}
// options: .status_item_fn = statusItem,
Title updates retitle the live NSStatusItem button without re-creating it; platforms without a tray-title seam keep menu updates and log the title gap once.
Windowed lists in markup
Use <virtual-window> to resolve one retained range before rendering, then <virtual-list> to request only its rendered rows. The window declares a stable literal id, an as range name, an item-count, and either item-extent or an exported Model-first extent-estimate helper. Optional gap, overscan, viewport-fallback, index-base, and anchor="leading|trailing" describe the complete content geometry.
<virtual-window id="records" as="range" item-count="{loaded}" item-extent="48" overscan="4">
<column>
<virtual-list window="range" each="rows" as="row" grow="1" label="Records">
<list-item key="{row.id}" focusable="true" on-press="select:{row.id}">{row.title}</list-item>
</virtual-list>
<status-bar>{range.first_visible_index}–{range.last_visible_index}</status-bar>
</column>
</virtual-window>
In a TypeScript core, rows(model: Model, range: VirtualListRange): readonly Row[] takes the complete SDK range from @native-sdk/core/events and returns exactly range.end_index - range.start_index rows in rendered-index order. An extent estimator has shape rowExtent(model: Model, index: number): number; its retained identity survives rebuilds. Each row has one keyed root. Ordinary scroll styling, on-scroll, and reach-start/end messages belong on virtual-list.
Each window has one root and exactly one consuming list; windows cannot nest. A range can pass through a template argument, and secondary window views have their own retained descriptors. The full range exposes visible and rendered bounds, content extent, row extent, and offset; use it for footer text without copying scroll state into the model. See examples/feed for variable-height estimates, stable row queries, and retained selection.
Native scrolling (macOS)
Zero app code: on macOS every non-virtualized scroll region — and every windowed virtual list (ui.virtualList), whose driver content size is the full virtual extent — is driven by an invisible NSScrollView — OS momentum and the system overlay scrollbar — while the engine renders the content. widget.value stays the offset of record, so the rebuild reconcile rule ("user offset survives rebuilds until the source offset changes"), automation snapshot offsets (scroll=[offset=..]), and Options.sync all work exactly as before; the engine-drawn scrollbar simply stops painting for natively driven regions. Programmatic scrolls still work: change the source offset (or scroll via keyboard/automation) and the runtime pushes it into the native scroller. GTK/Win32 and mobile embeds keep the engine's wheel physics unchanged. Nested-scroll saturation handoff (inner region exhausted, outer continues) is per-region native today: the inner region stops at its edge like a standalone scroller.
Overscroll is off by default, per region, on both paths. Scroll regions pin at their content edges — the native scroller gets non-elastic edges, the engine's wheel/kinetic physics clamp, and kinetic motion stops cleanly at the boundary. Bouncing is a per-region opt-in: overscroll="rubber_band" in markup (the scroll element only — the validator rejects it elsewhere with a teaching error) or ElementOptions.overscroll = .rubber_band in Zig views. The ScrollPhysics.overscroll design token (ScrollPhysicsOverrides in a theme) flips the app-wide default; per-region values override it, and .none pins a region regardless of the token. The rubber-band shape — excursion bound, resistance, spring-back rate — stays themable through the rubberband_* physics tokens.
Context menus: one declared menu, platform-decided presentation
Authors write ONE menu; the platform decides how it presents. The default is the real OS context menu at the pointer — NSMenu on macOS, TrackPopupMenu on Windows, GtkPopoverMenu on Linux — and the selection dispatches the item's typed Msg. On hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today), the SAME declared items present automatically as an anchored canvas surface at the click point, with the standard anchored-surface behavior (Escape and outside-click dismiss, late z-pass, window clipping). Never two authored menus, never a canvas imitation where the OS menu exists.
Markup declares the menu as a <context-menu> element — a DIRECT child of the interactive element whose right-click it answers (a hit-target kind, or an element made interactive by on-press/on-double-press/on-toggle/on-hold/on-drag). It is metadata, not content: it renders nothing in the row's flow. Children are menu-items (on-press required, disabled optional, the text content is the label) and bare <separator/>s, with if/else/for around them to swap or repeat items — a menu whose items all evaporate at runtime simply declares no menu (the All Notes row pattern). Conditional MENUS are spelled as conditional ITEMS: the <context-menu> itself takes no attributes and cannot sit behind a structure tag. No submenus: the platform channel carries flat items (label, enabled, separator) only.
<list-item on-press="open_note:{n.id}" label="{n.title}">
<text grow="1">{n.title}</text>
<context-menu>
<if test="{n.deleted}">
<menu-item on-press="restore_note:{n.id}">Restore</menu-item>
<menu-item on-press="purge_note:{n.id}">Delete Permanently</menu-item>
</if>
<else>
<menu-item on-press="copy_note_id:{n.id}">Copy</menu-item>
<menu-item on-press="trash_note:{n.id}">Delete</menu-item>
</else>
</context-menu>
</list-item>
The compiled TypeScript view supports one direct menu with at most 32 labeled menu-item leaves and bare separators. Items require on-press and may bind disabled; structure tags inside menus currently remain on the native markup backend. The menu is copied into the native tree arena before the compiled result expires.
The Zig builder's mirror is ElementOptions.context_menu — per-widget items in the chrome-menu shape with typed messages:
ui.listItem(.{
.on_press = Msg{ .select = entry.index },
.context_menu = &.{
.{ .label = "Open Section", .msg = Msg{ .select = entry.index } },
.{ .separator = true },
.{ .label = "Refresh Dashboard", .msg = .refresh },
},
}, entry.title)
The deepest declaring widget on the hit route wins; disabled items and separators are fine (enabled = false, .separator = true). Zero-code defaults need no declaration: editable text fields present the standard Cut / Copy / Paste / Select All menu wired to the existing clipboard actions, and a selected static text presents Copy (these defaults are presenter-only — without an OS menu they degrade to the keyboard clipboard paths). Touch long-press is design-noted for the mobile embeds: the iOS host's under-slop Pending touch state is the timer seam, pending a secondary-button leg in the embed ABI and UIEditMenuInteraction presentation.
Automation drives the native path honestly: snapshots list every widget's declared items in invocation order (context_menu=["Rename","Delete"], separators keep their slots, disabled items say so), widget-context-press <view> <id> performs the real secondary click (presenting the menu), and widget-context-menu <view> <id> <item-index> invokes an item — the selection dispatches as the same context_menu_action platform event a real pick produces; the journal records the complete widget invocation so replay reconstructs its pending request before selection, because the OS menu's tracking loop cannot be driven programmatically. Dead invocations fail by name (undeclared menu, index out of range, separator slot, disabled item). examples/notes row menus and examples/gpu-dashboard nav rows carry live menus; zig build test-example-notes, test-example-gpu-dashboard, and the runtime context-menu suite verify dispatch, the fallback surface, and the verb.
Elements
| Markup | Widget | Notes |
|---|---|---|
row, column |
flex containers | main axis horizontal / vertical |
stack, panel, card |
overlay containers | children stack on top of each other — gap can never space them and is a validation error (put a column/row inside for flow) |
scroll |
scroll_view | wrap multiple children in a column inside it |
list, grid |
list, grid | vertical stack / cell grid |
tabs, toggle-group, button-group, radio-group, breadcrumb, pagination |
row containers | children flow horizontally. Give every radio-group an accessible label; it is one logical radiogroup: descendant radios at any nesting depth share one Tab stop; arrows wrap while Home/End move to the edges, focus and selection move together, and selecting one clears the rest of the nearest group scope |
table > table-row > table-cell |
table, data_row, data_cell | rows only inside a table, cells only inside a row (for/if wrappers are fine); cells are text leaves, dispatch with on-press |
dropdown-menu |
dropdown_menu | vertical menu surface; children are menu-items. anchor="below|above" floats it against its PARENT's frame (see Pickers): late z-pass above the whole tree, window-clipped, auto-flipping at the window edges, zero flow space. Pair with on-dismiss |
accordion |
accordion | header via text attr; children show while selected, dispatch on-toggle |
alert, bubble |
surfaces | alert title via text attr; children stack inside. bubble hugs its message up to 80% of the thread (ghost exempt; explicit width wins) and takes one <reactions> child — the reaction pill straddling its bottom edge, one text run, dock via text-alignment (default end); text= on bubble itself is a teaching error (that channel belongs to the pill). Grouped runs are spacing, not vocabulary: 8 gap within a sender's run, 32 between turns |
dialog, drawer, sheet |
modal surfaces | root-relative and unaffected by ancestor scroll or clipping: dialogs center in the viewport, drawers span its width and dock to the bottom, and sheets span its height and dock to the right — title via text attr, wrap in <if> to show conditionally |
resizable |
resizable | engine-managed drag handle; width sets the initial width |
split |
split | two-pane horizontal splitter: exactly two element children (nest splits for more panes), the engine synthesizes the draggable divider between them. value binds the model-owned first-pane fraction (0 lays out at 0.5), on-resize names an f32 Msg variant dispatched with every applied fraction (echo it back through value — see Splitters), min-width on the panes bounds the drag, gap sets the divider band thickness. The divider is focusable: Left/Right (Shift for bigger steps) adjust, Home/End jump to the clamp edges |
tree |
tree | disclosure-tree container (vertical flow): descendant rows carrying role="treeitem" — at ANY nesting depth — form one roving keyboard focus set with the ARIA tree keymap. Up/Down walk visible rows (selection follows focus through each row's on-press), Left collapses an expanded row or moves to the parent row, Right expands a collapsed row or moves to the first child row, Home/End jump to the edges, Enter/Space activate. Expandable rows bind expanded and on-toggle; the model owns selection and expansion (collapsed children are simply not rendered) |
text, badge, tooltip |
text leaves | text content, {} interpolation allowed; text line policy via wrap ("true" word-wraps; "false"/unset paint one honest line, overflow eliding by default — overflow="clip" opts out), and text alone takes the typography rungs size="heading"/size="display" (themable token steps above title — section headings, hero stats, timer numerals). tooltip with anchor="above|below" floats against its parent (the stack wrapping trigger + tooltip, the dropdown pattern) and the RUNTIME owns its visibility — hover intent on the trigger: shows after tooltip-delay ms (default 600; "0" = instant) and immediately on keyboard focus; hides on leave, focus departure, Escape, or a press of the trigger (a press also closes the warm window), and a shared 400ms warm window after a pointer-hovered tooltip hides on leave (the only hide that warms) shows the next trigger's tooltip instantly; the model never hears hover. These are shadcn/ui's defaults (Base UI). Without anchor it stays a static leaf that paints whenever the view renders it |
text > span |
inline styled runs | mixed-style text in ONE wrapped paragraph: span children style runs with weight="regular|medium|bold", mono, italic, scale (a positive multiplier on the paragraph's base size — inline headings, hero stats), underline, foreground (token name); {bindings} interpolate inside spans; whitespace between runs collapses to a single space (none = the runs abut); spans do not nest, take no events, and the paragraph announces as one text run — see "Rich text" |
button, toggle-button, list-item, menu-item, toggle, switch, select, avatar |
text-bearing controls | label is the text content; button, toggle-button, list-item, and menu-item also take icon="save" — a vector icon drawn inline (buttons/toggle-buttons before the label, icon-only when the content is empty: add a label; list/menu items as a leading slot), ONE hit target whose icon follows the element's enabled/disabled tint (no overlay stacking, no duplicated on-press); tab strips are toggle-button children, so tabs get icons this way; select shows placeholder while empty and dispatches on-press; avatar renders initials, or a runtime image via image="{binding}" (see the Images section) |
segmented-control |
exclusive-choice control | standalone text-bearing segment; bind selected, dispatch on-press, and optionally add a vector icon; compose several in a row, or use tabs for the strip-and-panel pattern |
checkbox, radio, slider, progress |
value controls | checked, value (a 0..1 fraction on slider and progress; progress clamps out-of-range values at render, never an error); checkbox/radio visible labels are text content (<checkbox>Done</checkbox>), with text="..." as the equivalent binding-friendly form and label= alone naming one for accessibility without drawing a label; a radio selection TRANSITION (pointer, Space/Enter, grouped navigation, or accessibility selection) dispatches on-change when bound, then on-toggle, then on-press for compatibility — reactivating the already-checked radio has no new on-change edge, though a legacy fallback still receives the activation; a slider's value follows the source when it MOVES (model-driven progress renders every rebuild) and keeps the user's drag while the source replays the same value — use slider for seek bars, progress for display-only; a markup slider's on-change dispatches a PLAIN Msg with no value payload — mirror the applied value into the model with Options.sync (the Zig builder's on_value = Ui.valueMsg(.tag) does deliver the applied f32) |
text-field, input, search-field, combobox, textarea |
text entry | placeholder; edits via on-input, enter via on-submit on single-line kinds; in a default textarea, Enter (and Shift+Enter) inserts a newline and on-submit dispatches on primary+Enter (cmd on macOS, ctrl elsewhere). A chat composer opts into submit-on-enter="true": plain Enter submits, Shift+Enter remains a newline, and the primary chord still submits. search-field carries a built-in trailing clear affordance whenever it holds text (press the x, or Escape while focused — both clear through the text-edit path, so on-input hears it; no attribute, no external Clear button needed) |
status-bar |
status bar | text leaf: content only, no children |
separator, spacer |
separator, flexible space | separator is axis-aware: a horizontal rule in a column, a thin vertical divider in a row; give spacer a grow |
skeleton, spinner |
loading leaves | size skeleton with width/height |
icon |
vector icon leaf | name picks the icon: a bare literal is a curated built-in stroke icon (compile-checked; 49 names: search, plus, x, x-circle, check, check-circle, chevron-up/down/left/right, arrow-up/down/right, menu, panel-left, panel-right, settings, terminal, wrench, trash, edit, copy, external-link, play, pause, skip-back/forward, shuffle, repeat, music, volume, info, alert, download, save, folder, folder-open, file-text, sun, moon, eye, clock, git-pull-request, git-merge, git-branch, circle-dot, archive, refresh-cw, send); app:<name> reaches an icon the app registered at boot with canvas.icons.registerAppIcons (declare the table as pub const app_icons on the app root so native check verifies the name against the model contract), and one {binding} defers the choice to model data - an unknown resolved name draws the missing-icon fallback (a slashed circle) with a Debug warning naming the value; tint with foreground, size with width/height |
media-surface |
media surface leaf | composites a texture produced OUTSIDE the widget tree (video decoder, camera, an external renderer like mpv) into the layout like any widget — clipped, z-ordered, rounded. surface="{binding}" (required) binds the model-owned u64 surface id a Zig-tier producer targets (runtime.acquireMediaSurfaceProducer pushes RGBA8 frames, latest-wins, paced by the presented-frame clock; 0 = unbound, draws nothing; usable ids are nonzero values below the reserved bit 63). No intrinsic size — give it width/height or grow; display-only (presses fall through); label it (pictorial content). Texture contents are presentation chrome: goldens, reference screenshots, and session replay show the deterministic id-derived placeholder, never producer frames |
image |
runtime image leaf | draws a RUNTIME-REGISTERED image by its model-owned u64 ImageId — the id Cmd.imageLoad (TS) or fx.loadImage/fx.registerImageBytes (Zig) registered pixels under. image="{binding}" (required) binds a model field/fn; ids are model data, never markup literals, and 0 draws nothing (store the id only when the load reports loaded — see the Images section). source-x/source-y/source-width/source-height select one atlas region in decoded-image pixel coordinates (declare all four). No intrinsic size — give it width/height or grow; display-only (presses fall through); label it (pictorial content) |
code |
bare highlighted source/editor | source="{binding}" (required) provides source text and language="tsx" selects a literal lexer name; the component supplies no background, border, radius, shadow, or padding, so wrap it in a panel/card when chrome is wanted. It is read-only by default; editable on-input="edit" opts into multiline editing while retaining highlighting. It wraps by default, line-numbers opts into logical line numbers, added-lines="5" / removed-lines="2-4" add Geist-style diff rows without changing copied source, and wrap="false" preserves lines inside one horizontal scroll region. HTML-family highlighting distinguishes HTML/XML/SVG and JSX/TSX tags, attributes, strings, comments, and embedded expressions. Zig builder: ui.code(CodeOptions, source) |
markdown |
rendered markdown subtree | leaf; source is one {binding} — see "Markdown in markup" |
stepper > step |
composite stage track | active="{index}" (required) derives each step's completed/active/pending state; steps are text leaves (no attributes) joined by connectors; stepper also takes key, global-key, label |
timeline > timeline-item |
composite ledger list | items only inside a timeline (for/if fine); items are leaves — title (required), description, meta, indicator, variant, connector="false" on the last item, selected; on-press makes the whole item pressable with a trailing chevron |
chart > series |
composite data chart | series only inside a chart, and only series (the set is static — data varies through bindings); each series is a leaf — values="{binding}" (required) names a model []const f32 iterable, kind is line/area/bar (literal), color a token name, label the semantics name; chart takes y-min, y-max, grid-lines, baseline, x-labels, y-labels, hover-details, stroke-width, box options, label — see "Charts" |
context-menu |
consumed by its parent | right-click menu on its DIRECT parent (a hit-target kind or an element with on-press/on-double-press/on-toggle/on-hold/on-drag); metadata, never a flow child. Children: menu-items (on-press required, disabled optional, no icon) and bare separators, with if/else/for around them. Attribute-less; presents natively where the host has a menu presenter, as an anchored surface elsewhere — see "Context menus" |
input-group > textarea + input-group-actions |
composite grouped input | the composer shape: ONE bordered field wrapping exactly one textarea (first — document order is focus order) plus an optional input-group-actions row of controls inside the same border. The group wears the focus ring for its focused descendant and the textarea's own chrome dissolves automatically, so the whole group reads as one field; the textarea keeps its full behavior (text, placeholder, on-input, on-submit, autofocus, and optional submit-on-enter). Group takes label, width, height, min-width, grow, key, global-key; the actions row takes gap and holds ordinary elements (if/else/for work — swap send for stop while streaming) — put a <spacer grow="1"/> between leading and trailing controls (Ui.inputGroup/Ui.inputGroupActions are the Zig-view equivalents) |
Capabilities still awaiting a separate admission or ruling are image-backed icon_button content, data_grid per-column cell templates, and popover/menu_surface anchoring. segmented-control is a first-class markup leaf, and vector icon buttons use <button size="icon" icon="..." label="..." on-press="..." />. Charts ARE expressible: <chart> with <series values="{binding}"> children binding model f32 iterables — see the Charts section (.band series and dynamic series composition stay with ui.chart). Built-in vector icons ARE expressible: <icon name="search"/> (closed, compile-checked name set; Ui.icon is the Zig-view equivalent). App-authored icons: canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg")) parses any SVG in the common 24x24 stroke-icon dialect at comptime; register the parsed table once at boot with canvas.icons.registerAppIcons(&table) and draw by name via ui.appIcon(.{...}, "logo") or ElementOptions.icon — registered names render exactly like built-ins on every draw path. Markup <icon>/<button icon> stay built-in-only (the compiled engine validates names at comptime, where runtime registrations cannot exist — engine parity). Runtime images ARE expressible: <image image="{cover}" width="120" height="80" label="Cover art"/> and <avatar image="{user_image}">CT</avatar> bind a u64 ImageId model field/fn (the id is just model data; 0 draws nothing / keeps the initials fallback) — see the Images section; the image binding is required on the leaf (an unbound <image> is dead markup) and stays avatar+image scoped. Add all four source-x/source-y/source-width/source-height attributes to draw one decoded-pixel sub-rectangle from a shared atlas.
Attributes
Layout: gap (flow containers only — stacking containers stack/panel/card/alert/bubble/dialog/drawer/sheet/resizable layer their children, so gap there is a validation error, not silence: wrap the children in a column/row inside; on split it sets the divider band thickness), padding (uniform), grow, width, height (definite: the element is exactly that size — intrinsic content neither shrinks nor silently overflows it; resizable treats width as the initial width), min-width (a floor WITHOUT width's definite max — the element may grow past it but never shrink below; on split panes it bounds the divider drag), max-width (a ceiling WITHOUT width's definite min — the element still shrinks with a narrow parent; use a growing child inside a centered row for a responsive content column), wrap (text only: wrap="true" word-wraps at the width the element receives and reserves the wrapped height in columns; wrap="false" and unset are honest single-line — one line whose overflow follows overflow), overflow (text only, a teaching error elsewhere: what a single line does with content that does not fit — ellipsis, the default, elides behind a trailing … measured with the same metrics paint uses, right for width-constrained list-row titles; clip hard-cuts at the frame for fixed-format content like a duration column where "1…" beats nothing; there is deliberately no overflow-visible), text-alignment (start|center|end — text leaves, status bars, surface titles; controls that own their label placement ignore it), columns (grid only: fixed column count, omit for the derived near-square grid; a teaching error elsewhere), main (start|center|end|space_between), cross (stretch|start|center|end), virtualized, virtual-item-extent, anchor (dropdown-menu and tooltip, literal below/above: floats the surface against its parent instead of the flow — auto-flips when the preferred side does not fit, height clamps to the chosen side, x clamps into the window; an anchored tooltip's visibility is runtime-owned hover intent, unlike the model-owned dropdown), anchor-alignment (with anchor: start/end/stretch — stretch also widens the surface to at least the anchor's width, the select-menu look), anchor-offset (with anchor: literal gap in points, default 4), tooltip-delay (tooltip only, beside anchor — a teaching error elsewhere or without it: hover-intent show delay in ms, default 600, "0" = instant; keyboard-focus reveals are always immediate), overscroll (scroll only, a teaching error elsewhere: none pins the region at its content edges — the shipped default via the ScrollPhysics.overscroll token — rubber_band lets it bounce past them on both the engine and native paths, default follows the token).
Appearance/state: variant (default|primary|secondary|outline|ghost|destructive), size (the control scale default|sm|lg|icon on every sized element; on text also the typography rungs heading|display — named typography token steps (heading_size 28, display_size 48, themable like every token) for section headings and hero stats/timer numerals. The two axes stay apart: heading/display on a control is a teaching error naming text as their home, unknown values list the vocabulary, and numeric sizes are refused by design — retheme the typography tokens to move the whole scale), disabled, checked, selected, value, placeholder, icon (button, toggle-button, list-item, menu-item: vector icon drawn inline — buttons/toggle-buttons before the label, list/menu items as a leading slot; a teaching error anywhere else. A built-in name, app:<name>, or one {binding} resolving to such a name). One size register per row: every control class shares the control height at a given register (default 36, sm 31.5, lg 40.5 before density), so a toolbar/filter row reads as one height exactly when every control in it carries the SAME size — mixing size="sm" buttons with a default field renders two heights in one row, and hand-sized pressable panels (height="30") never land on the scale; compose rows from real controls at one register.
Focus: autofocus (focusable controls only — a teaching error elsewhere): moves keyboard focus to the element when it MOUNTS or when the bound value turns on, edge-triggered so holding it true never re-steals focus from the user. The TEA way to focus an editor on note-create (<text-field autofocus="{editing}" ...> or mount the field under an <if> with autofocus="true"; Zig views use ElementOptions.autofocus) and to give keyboard-first apps their first focus without a click.
Semantics: role (listitem, treeitem, button, ...; treeitem also makes the row part of its tree's roving keyboard focus set), label (accessible name — it REPLACES the element's text content as the announced name, so snapshot greps and screen readers see the label, never the text; don't label an element whose visible text you grep for), expanded (tree rows: disclosure state, model-owned — omit on leaves). Accessible names are ENFORCED: an interactive control with no text content, no text=, and no label= is a validation error (icon-only controls need label; text-entry controls need label or placeholder), unknown/misused literal roles are errors (role="tree" on a text leaf can never hold rows), unnamed avatars and labels duplicating the text content are warnings (label="" marks an image decorative). Zig-built trees get the same discipline from canvas.expectA11yAuditSweepClean (missing names as the bridges would announce them, focusables clipped out of keyboard reach, identically labeled siblings) — adopt it next to the layout sweep.
Identity: key (sibling-scoped), global-key (parent-independent — use for items that move between containers, e.g. board cards; ids then survive reparenting).
Window chrome: window-drag="true" (Zig: .window_drag = true) marks the element as a window-drag surface for hidden-titlebar windows — pressing its background or plain text/icons inside moves the WINDOW (drag starts only on actual movement), double-click zooms per the OS convention, and press-claiming children (buttons, fields) stay fully interactive via the ordinary press fall-through. macOS-only; elsewhere the press is dead space. See "Hidden titlebar" below.
Render channel (Zig-only, no markup attributes): ElementOptions.opacity and ElementOptions.transform wrap the element's emitted commands without reflowing siblings — the defaults (1, identity) emit nothing, opacity 0 culls painting (pair with disabled when fading interactive content), and a transform moves both rendering and pointer hit-testing while accessibility frames stay at the layout frame. Pair with UiApp.Options.animations for tweening.
Numbers are plain (gap="12"), booleans are true/false or a binding.
When children's minimum sizes exceed their container, debug builds log a zero_canvas_layout diagnostic naming the container, axis, and overflow in pixels — flex overflow is never silent. In Zig views, .gap on a stacking kind (ui.panel(.{ .gap = 8 }, ...)) logs a zero_canvas_ui warning in debug builds with the same lesson — it never fails the build.
Chips: exclusive selection with selected=
The chip pattern — an exclusive group where the model owns which one is active — is a toggle-group of toggle-buttons (or plain buttons) whose selected= binds the model:
<toggle-group gap="2" label="Theme">
<for each="theme_prefs" as="p">
<toggle-button size="sm" selected="{p == theme_pref}" on-toggle="set_theme:{p}">{p}</toggle-button>
</for>
</toggle-group>
A toggle-button whose source asserts selected (this rebuild or the previous one) is model-driven: the source wins over the runtime's retained toggle on every rebuild, so exactly the model's selection is active — pressing a chip dispatches the Msg, the model moves the selection, and the old chip deactivates. Without a selected= that ever asserts, a toggle-button is uncontrolled: the runtime retains its pressed state across rebuilds (the multi-select formatting-bar case — bold/italic chips with zero app wiring). button with selected= is always model-driven (buttons never retain state) and dispatches on-press; toggle-button dispatches on-toggle (its activation is the toggle intent — an on-press there never fires). Model-driven chips need a handler that actually moves the model: a chip whose Msg is ignored keeps its retained press until the model asserts its selected=.
Pickers: select is the trigger — compose the options as an ANCHORED dropdown
select and combobox are trigger controls, not complete pickers: select renders the closed dropdown shape (current value as content, placeholder while empty, on-press to open) and combobox is a text entry with a menu chevron — neither owns an options list. There is no options= attribute (the closed grammar has no list-valued attributes — list-shaped vocabulary is element children, the way <context-menu> declares its items); the options ARE the composition — a dropdown-menu of menu-items under an if, beside the trigger inside a stack, floated with anchor:
<stack>
<select placeholder="Pick a repo" text="{current_repo}" on-press="toggle_repo_picker"/>
<if test="{repo_picker_open}">
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_repo_picker">
<for each="repos" key="name" as="r">
<menu-item on-press="pick_repo:{r.name}" selected="{r.name == current_repo}">{r.name}</menu-item>
</for>
</dropdown-menu>
</if>
</stack>
How the pieces fit, all model-owned (TEA):
- Open state is the model's.
toggle_repo_pickerflips the bool; the surface exists only while theifrenders it. There is no hidden engine open flag. anchorfloats the menu. The dropdown positions against its PARENT's frame (thestack, sized by the trigger): below it by default, flipping above when it doesn't fit and the other side has more room, height clamped to the chosen side, x clamped into the window. It consumes NO space in the flow (siblings never reflow), paints in a late z-pass above the whole tree, and escapes every ancestor scroll/clip region — window-clipped, not pane-clipped.anchor-alignment="stretch"widens it to at least the trigger's width (the select look).on-dismisscloses it model-side. Escape and a click outside the menu dismiss the surface and dispatch the Msg;close_repo_pickerclears the bool. Escape works even when the trigger took no focus (a plain-text crumb): with no relevant focus chain it dismisses the topmost mounted anchored surface. The engine hides the surface immediately (the optimistic echo), and the next rebuild's source tree is truth — a model that keepsopentrue gets it back. Clicking the TRIGGER while open never double-fires: the anchor region owns its surface's toggling, so onlytoggle_repo_pickerdispatches.- Items close on pick.
pick_reposets the value AND clears the open flag — a click inside the surface never dismisses. - Keyboard: ArrowDown/Up on a trigger with a mounted menu enters its selected visible row, or the first/last eligible row. Arrows move without wrapping; Home/End choose the visible edges. Enter/Space activate items. Tab/Shift+Tab dismiss without committing and return focus to the trigger; the next Tab continues through ordinary controls. Escape also dismisses from the trigger or menu and returns focus.
- Automation sees everything: the floating menu and its items appear in widget snapshots at their real frames and
widget-click <item-id>works while it is open.
combobox composes the same way (the model filters the for source as the user types via on-input). Markup also supports <popover> and <menu-surface> with the same anchor and dismissal attributes. The Zig mirror is ElementOptions.anchor/anchor_alignment/anchor_offset + on_dismiss on dropdown_menu, popover, or menu_surface, built with ui.eachCtx for the options. Budget: at most 16 anchored surfaces may be mounted per view (max_canvas_widget_anchored_per_view, loud error.WidgetAnchoredSurfaceLimitReached) — an anchor inside a <for> body is almost always a mistake.
Splitters: split panes with a model-owned fraction
split is the resizable two-pane seam: exactly two element children, and the engine synthesizes the draggable divider between them (resize cursor, focusable, ARIA separator whose value is the fraction). The fraction is MODEL-OWNED — the runtime applies each drag/keyboard step as an optimistic echo, dispatches on-resize with the applied fraction, and the model echoes it back through value so the next rebuild lays the panes exactly there:
<split value="{sidebar_split}" on-resize="sidebar_resized">
<column min-width="150">…sidebar…</column>
<split value="{list_split}" on-resize="list_resized">
<column min-width="220">…list…</column>
<column min-width="280">…editor…</column>
</split>
</split>
on-resizenames an f32 Msg variant (sidebar_resized: f32);updatestores it (model.sidebar_split = fraction). The delivered fraction is the value the runtime already applied and clamped, so echoing it never fights the reconcile.min-widthon the panes bounds the drag — the divider clamps so neither pane shrinks below its floor, on drag, keyboard, and layout alike.- Uncontrolled works too: without
on-resize, the divider position survives rebuilds under the source-wins reconcile (a source-sidevaluechange wins), but pane CONTENT lays out at the declared fraction until the model echoes — bind the handler for the exact controlled loop. - Keyboard: Tab reaches the divider; Left/Right step the fraction (Shift for 2x), Home/End jump to the clamp edges. Automation drives it with
widget-drag/widget-key, and snapshots show the divider asrole=separatorwith the fraction as its value. - Animated resize:
resize-duration="180"(milliseconds,splitonly — a teaching error elsewhere) makes the boundvaluea target — a model-driven move (a collapse toggle, not a drag echo) eases the rendered fraction there one presented frame at a time instead of snapping, dispatching the sameon-resizeechoes a drag would;resize-easing(linear/standard/emphasized/spring) shapes the ramp and needs the nonzero duration beside it (alone it is a teaching error), and reduced-motion appearances snap automatically — apps declare nothing extra. - Three panes = nested splits, as above. More than two children is a validation error (put conditional content inside a pane).
Trees: disclosure rows with the ARIA tree keymap
tree turns a rail of pressable rows into a keyboard-navigable disclosure tree. Rows are ROLE-driven: any pressable element carrying role="treeitem" — at any nesting depth under the tree — joins one roving focus set:
<tree gap="2" label="Folders">
<for each="folderRows" key="id" as="f">
<panel role="treeitem" expanded="{f.expanded}" on-press="select_folder:{f.id}" on-toggle="toggle_folder:{f.id}" label="{f.label}">
<row gap="8" cross="center"><icon name="folder"/><text grow="1">{f.name}</text></row>
</panel>
</for>
</tree>
- Up/Down walk the visible rows in tree order, across nesting levels. Selection follows focus: each move dispatches the landed row's
on-press, so the model owns the selection exactly like a click. - Left/Right are disclosure keys: Left on an expanded row dispatches its
on-toggle(collapse); on a collapsed row or leaf it moves focus to the PARENT row. Right on a collapsed row dispatcheson-toggle(expand); on an expanded row it moves to the first child row. - Home/End jump to the scope's first/last row; Enter/Space activate (
on-press). - Expansion is model-owned: expandable rows bind
expanded(omit it on leaves) and the model renders child rows only while expanded — collapsed subtrees are simply not in the tree, so "visible rows" needs no engine bookkeeping. Flat rails (the notes folder list) are honest trees of leaves: Up/Down/Home/End/Enter work, Left/Right are inert. - Single-select: selecting a row clears the previous selection across the WHOLE tree scope (rows nest, so this is not per-parent).
Press-and-hold: on-hold
on-hold is the click-acts, hold-reveals menu-button shape — a control that acts on click and offers more on hold: a pointer held ~350 ms dispatches the hold Msg (the release then presses nothing), a quick click dispatches on-press as usual, and a right/ctrl-click whose route offers no context menu dispatches the hold Msg immediately (a declared <context-menu> always wins the right-click — hold is the primary-button gesture, not the context-menu channel). Like on-press, binding it makes any element pressable. The breadcrumb-switcher pattern: on-press selects the crumb, on-hold opens an anchored dropdown-menu of its siblings — an app-designed hold-reveal surface, distinct from the row's right-click menu. Both legs are live-drivable: native automate widget-hold <view> <id> runs the pointer+timer gesture, widget-context-press <view> <id> the secondary click.
<button on-press="select_crumb:{c.id}" on-hold="open_crumb_menu:{c.id}">{c.name}</button>
Select, then act: double press and row-level Enter
The desktop list convention — click selects, the primary action (open the record, play the track) rides the double click and Enter — is two bindings on the same row. on-double-press in markup (ElementOptions.on_double_press in Zig) dispatches on the release whose runtime-derived click count reached 2, in place of a second press: the first click still dispatches on-press on its own release, so the pairing is additive, never a delay — no press timer, no swallowed first click. Like on-press, binding it makes the element a hit target; a widget with no double handler treats a double click as two single clicks. The keyboard mirror is row-level Enter: on a list-item, on-submit grows a second home beyond text entry — with a submit handler bound, plain Enter on a keyboard-focused row dispatches it as the row's PRIMARY action while Space keeps the select activation (on-press); rows without one resolve Enter as select, unchanged. Markup pairs the gestures directly (<list-item on-press="select_track:{t.id}" on-double-press="play_track:{t.id}" on-submit="play_track:{t.id}">{t.title}</list-item>), and tests drive the pointer half through msgForPointerClick and the keyboard half through msgForKeyboard. examples/soundboard's Zig track rows remain the live builder reference: on_press select, on_double_press play, on_submit play.
Widget budgets and virtualization
Every view has fixed per-view capacities (src/runtime/canvas_limits.zig): 1024 retained widget nodes (max_canvas_widget_nodes_per_view — the budget that matters for tree design; semantics and spans match it), 64 KiB retained widget text, 512 declared context-menu items summed across all widgets of the view (max_canvas_widget_context_menu_items_per_view — separators count as items), 64 chart series / 16384 chart points summed across all charts of the view (max_canvas_widget_chart_* — ui.chart downsamples every series to 256 points, so this is 64 maximal series or hundreds of sparklines), and per-frame content budgets (2048 commands, 8192 glyphs, 32 KiB frame text, 2048 path elements shared by icons and charts). Overflow is loud: error.WidgetLayoutListFull / error.WidgetNodeLimitReached / error.WidgetContextMenuLimitReached / error.WidgetAnchoredSurfaceLimitReached (at most 16 anchored floating surfaces mounted per view — max_canvas_widget_anchored_per_view) fail tests under the harness's propagate policy and log a teaching diagnostic naming the budget in production (the app degrades to the previous frame). Watch headroom without overflowing: automation snapshots report widget_nodes=N/1024 widget_semantics=N/1024 context_menu_items=N/512 on every gpu_surface view line.
Budget rules of thumb: 1024 nodes is roomy for a three-pane desktop app (~500 nodes measured for a dense sidebar + markdown detail + run surface), but node count scales with what is MOUNTED, not what is visible — so bound every unbounded collection:
- Dataset-scale uniform rows (feeds, logs, tables of thousands+): use the WINDOWED virtual list (
ui.virtualWindow+ui.virtualList, Zig views — see the next section). The view builds only the visible window; the runtime owns the scroll; budgets stay viewport-sized at 100k items. virtualizedonscroll/list/grid/table(withvirtual-item-extentfor fixed-extent items) lays out only the visible window + overscan; a 10,000-item list materializes ~viewport/extent nodes. It bounds NODES, not your source data: the builder still walks every item, so it suits row sets the model already holds (hundreds). Legacy virtualized containers without a declared item count are app-driven for scrolling (wheel offsets do not mutate them).- For non-uniform content (chat transcripts, ledgers, diffs), keep a bounded window in the model and slide it with
on-scroll(see Messages) or explicit paging — the window follows the scrollbar instead of mounting everything. - Remember multi-node rows multiply: a 4-node row × 50 mounted rows is 200 nodes before chrome.
Windowed virtual lists (Zig views): 100k rows, viewport-sized budgets
The honest infinite-scroll primitive. The RUNTIME owns the viewport math (retained scroll offset + viewport → visible index range, from a fixed per-item extent), the MODEL owns the data, and the view is the seam between them: ask ui.virtualWindow for the visible range, build ONE keyed node per item in it, hand both to ui.virtualList. The list is a runtime-scrolled scroll region — engine wheel/kinetic/keyboard everywhere, the native scroll driver on macOS — whose scrollbar spans the FULL virtual extent (item_count × stride), and every scroll observation re-derives the view so the window follows the offset with no app wiring.
const options = Ui.VirtualListOptions{
.id = "timeline", // stable identity: global key + scroll-state lookup
.item_count = model.loaded, // TOTAL items the model holds right now
.item_extent = 84, // fixed row height (v1 contract: uniform rows)
.overscan = 4,
.grow = 1,
.on_reach_end = .load_more, // infinite fetch: update appends the next batch
};
const window = ui.virtualWindow(options);
const rows = ui.arena.alloc(Ui.Node, window.itemCount()) catch { ui.failed = true; return ui.column(.{}, .{}); };
for (rows, 0..) |*row, offset| {
const index = window.start_index + offset;
var node = rowView(ui, model, index);
node.key = .{ .int = @intCast(index) }; // identity = the ITEM, not the slot
row.* = node;
}
return ui.virtualList(options, window, .{rows});
Rules:
- Key every row by item identity (index or id). A row that scrolls away and back returns under the same structural id, so engine-owned row state and model-owned per-item state (selection washes, like counts keyed by index in the model) survive window shifts.
- No
on_scrollneeded. The runtime re-derives the view on scroll for mounted virtual lists; bindon_scrollonly when the model wants to observe the position. Do not echo an offset intovalue—virtualListmirrors the runtime offset itself. on_reach_endhas hysteresis built in: fires once when a scroll comes within one viewport of the end, re-arms past 1.5 viewports — which appending a batch causes on its own by growing the extent. One Msg per approach, never a fetch storm. It works on ANY scroll container (on-reach-endonscrollin markup).on_reach_startis the exact mirror for the content START (load older history; Zig
Truncated - read the full file at https://github.com/vercel-labs/native/blob/HEAD/skill-data/native-ui/SKILL.md.
