Compare commits

...

71 Commits

Author SHA1 Message Date
Chris Tate e9877e3259 chore(release): prepare v0.9.4
- Synchronize CLI, core, platform package, and example versions to 0.9.4.
- Add release notes for model-driven window restore policies and iterative rebuild improvements.
- Document the ScriptC 0.0.33 and Node 24 toolchain update.
2026-08-17 23:13:08 -05:00
Chris Tate 046ea270a4 chore(core): upgrade scriptc to 0.0.33 (#384)
* chore(core): upgrade scriptc to 0.0.33

- Pin scriptc 0.0.33 across Native packages and refresh compiler surface and calibration artifacts.
- Require Node 24 and route check/build tooling through scriptc's published compile-cache bootstrap.
- Enable and verify dev/release library profiles with synchronized docs, tests, and package mirrors.

* fix(ci): use Node 24 for TypeScript builds
2026-08-17 23:01:38 -05:00
Chris Tate f6e4d99f09 fix(build): keep iterative app rebuilds local (#382)
* fix(build): keep iterative app rebuilds local

- Stabilize generated core and service ABI artifacts by content so implementation-only service edits never rebuild the app core.
- Split primary markup and app code into independently cached objects, reducing warm markup rebuilds to data compilation plus linking.
- Add rebuild explanations, phase timing/RSS summaries, and forward-compatible scriptc dev profiles through its published binary.

* fix(build): launch ScriptC correctly on Windows

* fix(build): address PR review findings

* fix(build): preserve cached app link inputs

* fix(build): invalidate cores on SDK module edits
2026-08-17 21:30:30 -05:00
Chris Tate 781b7f9653 feat(windows): expose model restore policy (#381)
* feat(windows): expose model restore policy

- Forward restore policy from model-declared windows into platform options.

- Expose matching TypeScript types, defaults, validation, and documentation.

- Cover fresh center-on-primary windows through the null platform.

* docs(windows): clarify restore policy support
2026-08-17 18:01:47 -05:00
Chris Tate b25cefe318 chore(release): prepare v0.9.3 (#380)
- Synchronize CLI, core, platform package, and example versions to 0.9.3.
- Add release notes and contributors for all changes since v0.9.2.
2026-08-17 15:14:54 -05:00
Chris Tate ef3ba18168 Add platform-correct Command Backspace (#377)
* Add platform-correct Command Backspace

- Add semantic line-start deletion across Zig and TypeScript text mirrors.
- Preserve platform keymaps, controlled state, undo, and replay behavior.
- Cover all editable widgets and document the shortcut.

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>

* Fix Command Backspace edge cases

* Fix platform-specific Command Backspace test

---------

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-17 14:46:23 -05:00
Chris Tate 465a163e27 feat: add model-driven theme state (#378)
* feat: add model-driven theme state

- Add the TypeScript themeState helper across checker, generated ABI, and UiApp runtime.
- Preserve system accessibility behavior while supporting model pack, scheme, and accent precedence.
- Document the API and migrate gpu-components with runtime and replay coverage.

Co-authored-by: MohakBajaj <77928693+MohakBajaj@users.noreply.github.com>

* fix: constrain theme state optional fields

---------

Co-authored-by: MohakBajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-17 08:51:57 -05:00
Chris Tate a33d579177 fix(macos): preserve file drop targets (#374)
* fix(macos): preserve file drop targets

- Route AppKit drops with labeled, view-local coordinates across canvas, WebView, and window fallback paths.
- Preserve drop metadata through Zig and cover widget routing, TypeScript contracts, docs, and examples.

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>

* fix(macos): preserve file drop coordinates

* fix: keep file drop targets sized

* fix: keep capability webview interactive

* fix: constrain capabilities window width

---------

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-16 23:11:12 -05:00
Chris Tate 393a0ed36e fix(app-runner): load manifest menus and commands (#376)
* fix(app-runner): load manifest menus and commands

- Resolve app.zon commands, menus, and shortcuts consistently across live and replay runners.
- Add zero-config TypeScript coverage, automation, and documentation.

Co-authored-by: MohakBajaj <77928693+MohakBajaj@users.noreply.github.com>

* test(app-runner): verify manifest menu registration

* fix(automation): escape menu snapshot catalogs

---------

Co-authored-by: MohakBajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-16 23:04:07 -05:00
Chris Tate e8f9e4ee50 Fix large TypeScript Msg union compilation (#375)
- Derive comptime scan quotas from Msg shape and identifier bytes across generated shims, persistence, channels, and environment routing.
- Keep tag-skew diagnostics precise and teach future generated-code quota failures without blaming contracts.
- Add 160-arm compile/link and full TypeScript pipeline regression coverage.

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-16 21:46:23 -05:00
Chris Tate 659c893b29 Fix combobox Enter submit precedence (#373)
- Prefer bound combobox submit handlers over Enter trigger activation.
- Cover builder, markup, runtime, menu-selection, and command dispatch paths.
- Document the TypeScript-first on-submit composition and keyboard behavior.

Co-authored-by: Mohak Bajaj <77928693+MohakBajaj@users.noreply.github.com>
2026-08-16 21:46:14 -05:00
Chris Tate 8d0da34e62 chore(release): prepare v0.9.2 (#372)
- Synchronize CLI, core, platform package, and example versions to 0.9.2.
- Add release notes and contributors for all changes since v0.9.1.
2026-08-16 20:22:43 -05:00
Chris Tate 1c1fba0c0f fix(macos): place fresh windows correctly (#369)
* fix(macos): place fresh windows correctly

- Separate restored, explicit, and default window placement from persistence policy.
- Honor explicit origins and restore policy across platform seams and both macOS hosts.
- Add placement coverage, update docs, and remove obsolete example workarounds.

* fix(macos): honor window placement policies

* fix(macos): correct window placement policies

* fix(macos): preserve window placement contracts

* fix(runtime): restore secondary window placement
2026-08-16 19:06:29 -05:00
Chris Tate 86fecf6cee fix(macos): report content rects in window frame events (#370)
* fix(macos): report content rects in window frame events

- Emit content geometry consistently from the AppKit and CEF hosts.
- Preserve layout and restore round-trips without titlebar drift.

Co-authored-by: Sepehr Safari <25853688+sepehr-safari@users.noreply.github.com>

* fix(macos): restore CEF content geometry

---------

Co-authored-by: Sepehr Safari <25853688+sepehr-safari@users.noreply.github.com>
2026-08-16 17:18:48 -05:00
Chris Tate 23d0f5908a feat(images): decode photos to fit runtime budgets (#366)
* feat(images): decode photos to fit runtime budgets

- Decode encoded photos to fit the app's registered-pixel budget across platform codecs.
- Add a validated 1-8 MiB app.zon image budget and independent 8 MiB source bound.
- Cover deterministic pixels, 1080p loading, replay, ABI plumbing, and documentation.

Co-authored-by: Sepehr Safari <25853688+sepehr-safari@users.noreply.github.com>

* fix(images): honor raised budgets across hosts

* fix(images): cover generated and non-mac hosts

* fix(images): harden source and replay limits

* fix(images): fit Android panoramas before validation

---------

Co-authored-by: Sepehr Safari <25853688+sepehr-safari@users.noreply.github.com>
2026-08-16 16:26:36 -05:00
Chris Tate 919d0e6cdf fix(canvas): respect link span underline flag (#368)
- Render span underlines only when explicitly enabled.
- Preserve conventional underlines for Markdown-generated links.
- Cover linked spans with and without underline decoration.

Co-authored-by: Sepehr Safari <25853688+sepehr-safari@users.noreply.github.com>
2026-08-16 16:02:39 -05:00
Chris Tate 5d7fee8262 schema and rendering fixes (#367)
* canvas: allow checkbox and radio label content

* canvas: add interaction washes to actionable surfaces

* canvas: keep selection control geometry and fills consistent

* canvas: separate switch thumb and track colors

* canvas: preserve resolved colors through disabled washes

* canvas: skip zero-width badge and button strokes

* canvas: tint destructive alerts and align compact content

* docs: regenerate canvas control previews

* canvas: preserve text attributes on text-bearing controls

* canvas: fast-path idle layout container rendering

* canvas: keep authored row fills as the rest state

* bench: recalibrate full-view toggle budget

* canvas: complete schema and rendering fixes

- Enable checkbox and radio labels consistently across schema, markup engines, accessibility, docs, and previews.
- Unify actionable state, disabled-color, variant-accent, and zero-width stroke behavior across controls and surfaces.
- Correct compact control geometry and destructive alert styling/alignment with comprehensive golden and gate coverage.

* canvas: address schema review findings

* canvas: fix actionable state routing

* canvas: fix drag menus and disabled control tokens
2026-08-16 15:05:24 -05:00
Chris Tate 5c32accf12 fix(runtime): reveal autofocus targets and carets (#364)
* fix(runtime): reveal autofocus targets and carets

- Share scroll-then-verify focus reveal across keyboard, autofocus, and automation.
- Preserve selection while revealing collapsed end carets inside text editors.
- Cover scrolled focus flows and document the autofocus contract.

* fix(runtime): make focus reveals transactional

* fix(runtime): make widget focus reveal transactional
2026-08-16 08:02:28 -05:00
Chris Tate d575734635 Fix anchored surface correctness (#363)
* fix(canvas): dismiss anchored surfaces without focus

* fix(canvas): relayout anchors after scroll restore

* fix(canvas): place modal surfaces against root

* fix(canvas): address anchored surface review issues

* fix(canvas): unify window surface behavior
2026-08-15 22:26:04 -05:00
Chris Tate ee63266095 Implement logical canvas radio groups (#361)
* Implement logical canvas radio groups

- Scope nested radios as one roving-focus, single-selection group.
- Align radio keyboard, pointer, and handler dispatch behavior.
- Expose radiogroup accessibility semantics and document the contract.

* Fix radio group accessibility edge cases

* Fix radio group focus traversal edge cases

* Fix radio group keyboard and naming semantics

* fix: preserve radio selection semantics
2026-08-15 18:36:17 -05:00
Chris Tate e7c161970c Add configurable accessory startup (#358)
* Add configurable accessory startup

- Add manifest, runner, and macOS host support for flash-free Accessory startup.\n- Require a tray affordance and cover launch/runtime composition with tests.\n- Update the TypeScript menu-bar example and lifecycle documentation.

* Fix flash-free accessory startup

* Enforce tray requirement when packaging accessories
2026-08-15 00:52:34 -05:00
Chris Tate ddd975e4ea chore(ovation): checkpoint agent step (#357) 2026-08-14 18:03:46 -05:00
Chris Tate 474cb5e364 fix(core): pin scriptc tuple normalization fix (#356)
- Upgrade Native SDK compiler dependencies and generated references to scriptc 0.0.31.
- Add a real compiled-core ABI regression for bare-model and effect-tuple returns.
- Centralize test pin lookup and remove release numbers from comments and prose.

Co-authored-by: John Lindquist <36073+johnlindquist@users.noreply.github.com>
2026-08-14 17:27:26 -05:00
Chris Tate e6ac6ac4fc fix(linux): avoid GTK alert dialog crash (#354)
- Initialize GtkAlertDialog with a valid empty format string.
- Add regression coverage preventing a NULL constructor argument.

Co-authored-by: ElSebas41 <189925713+ElSebas41@users.noreply.github.com>
2026-08-14 09:53:19 -05:00
Chris Tate 0ecdc7d2e9 feat(core): expose model-declared windows to TypeScript (#351)
* feat(core): expose model-declared windows to TypeScript

- Add canonical TypeScript window descriptors with close-policy and close-command routing.
- Compile and hot-reload label-addressed secondary-window markup in generated launchers.
- Cover quit/hide behavior end to end and port the TypeScript system-monitor settings window.

* fix(core): harden TypeScript window declarations

* fix(core): validate TypeScript window views

* fix(core): validate returned window descriptors

* fix(core): harden TypeScript window contracts
2026-08-14 09:30:18 -05:00
Chris Tate e924d7fcac feat(examples): make the feed reader the end-to-end services showcase (#352)
* feat(examples): make the feed reader the end-to-end services showcase

* fix(examples): contain feed results in scroll pane

* fix(core): preserve service facade unbound metadata
2026-08-13 22:34:57 -05:00
Chris Tate 41c4cdc47a feat(files): add delete file effects (#350)
* feat(files): add delete file effects

- Expose Cmd.deleteFile as the primary TypeScript API with checked routing and documentation.
- Add real, fake, permission, journal, and replay support through the shared file-effect engine.
- Cover compiled cores across macOS, Linux, and Windows, including reliable Windows append behavior.

* fix(files): await Windows append completion

* fix(files): preserve final symlink on delete
2026-08-13 21:00:48 -05:00
Chris Tate 31d5b202bc feat(mobile): compile TypeScript cores and services for mobile targets (#346)
* feat(mobile): compile TypeScript cores and services for mobile targets

The external core and service compile drivers admit the pinned
compiler's three mobile triples — aarch64 iOS device and simulator
(macOS build host, iOS 15.0 floor) and aarch64 Android (any desktop
host, API 26 floor) — as library archives only, mapping the build
graph's Zig triples onto the compiler's own spellings and threading
the Android NDK location the way the graph already threads its zig.
The service executable lane refuses mobile targets with the
in-process pointer: no child process exists there, so the carrier
resolution turns "auto" into the in-process pool on iOS/Android and
teaches on an explicit "child", while desktop behavior is unchanged.

Mobile app builds stage a generated mobile entry beside the desktop
wiring: it satisfies the embed host's AppDef contract over the same
mirror, markup, and registry, drives the canonical mobile scene plus
the manifest's declared chrome, owns the service pool (markers and
stream relays live in the shim-installed app-data directory), and
delivers that directory through envMsgs. The compiled archives merge
into the embed static library the host tiers already link — flattened
to plain objects on Android, where Zig's ELF static-library emission
stores archive inputs as nested members the NDK link would skip.

A mobile execution lane (scripts/mobile-e2e.sh, NATIVE_SDK_MOBILE=1
in either gate tier) stages a battery over the service fixture's
compiled core and service archives, packages the fixture app for both
mobile targets and a services-free example for Android, then executes
the battery on a booted iPhone simulator and a headless arm64
emulator: typed pool results across update round trips, trap
isolation poisoning exactly one instance, and a journal replay that
reproduces the recorded model without initializing the archive. All
checks pass on both device classes. The TypeScript and services
chapters and the ts-core/ts-services skills state the mobile matrix;
persistence, boot images, and URL media caching remain unwired on
mobile.

* fix mobile TypeScript app wiring

* fix(mobile): validate TypeScript package outputs
2026-08-13 18:13:37 -05:00
Chris Tate 2bc942db46 feat: support close policies on secondary windows (#349)
- Expose UiApp.WindowDescriptor.close_policy and forward it through window creation.
- Preserve hide/reopen identity while retaining existing platform safety gates.
- Document and test quit, hide, and unsupported-host lifecycle behavior.
2026-08-13 17:37:27 -05:00
Chris Tate 199b89e06d feat: add actionable notifications (#347)
* feat: add actionable notifications

- Add stable replacement identifiers and paired notification actions across SDK APIs.
- Dispatch activations through the normal command path on macOS, Windows, and Linux.
- Advance the TypeScript wire generation and cover validation, test platforms, and docs.

* fix: secure notification action dispatch
2026-08-13 16:33:37 -05:00
Chris Tate 716eb27c53 feat(core): classify subset rules as guarantee or deferred (#345)
Every NS rule in the diagnostics catalogue carries class: "guarantee" |
"deferred". Guarantee rules protect a core invariant (determinism and
replay, fixed shapes, immutability of shared data, the one text
representation) and are permanent; deferred rules (NS1011, NS1019,
NS1040, NS1042, NS1044) wait on an easing decision and their
diagnostics say the capability is deliberately deferred, not
impossible.

Fix text names the concrete service alternative where one exists:
NS1002 (network via the generated client), NS1005 (clock reads),
NS1011 (Map/Set transforms), NS1040 (regex matching), NS1041 (JSON
parsing returning a typed record).

Docs and skills frame the core and services by role: the core is the
app's deterministic logic; services do the app's imperative work.
2026-08-13 14:15:06 -05:00
Chris Tate baef0d96d3 feat(storage): harden file effects (#339)
* feat(storage): harden file effects

- Add bounded streaming reads, atomic write sinks, stat, and append effects.
- Gate external paths with symlink-safe filesystem permission checks.
- Preserve deterministic record/replay through content-addressed stream blobs.

* fix(storage): harden file effect lifecycles

* fix(storage): harden stream replay and key ownership

* fix(storage): keep stream sink on rejected chunks

* fix(storage): address file stream review findings
2026-08-13 14:10:10 -05:00
Chris Tate f228e861d1 Upgrade scriptc to 0.0.29 (#344)
- Pin scriptc and its compiler/runtime lock data to 0.0.29.
- Refresh service contracts, compatibility fixtures, calibration, and documentation.
- Regenerate the service compile-surface reference; the 0.0.29 surface manifest carries no entry changes from 0.0.28 and adds a coverage statement for the mobile library targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android).
2026-08-13 13:56:28 -05:00
Chris Tate 6ec836c491 feat: support multiple macOS status items (#343)
- Add keyed status-item lifecycle and independent live updates across the runtime and macOS hosts.
- Expose model-driven TypeScript and Zig collection APIs with status-item-aware events.
- Extend automation, journaling, documentation, and regression coverage.
2026-08-13 13:14:16 -05:00
Chris Tate 9ce0370181 chore(release): prepare v0.9.0 (#341)
- Add complete v0.9.0 release notes and contributor credits.
- Synchronize CLI, core, platform, tool, and example version pins.
- Discover every TypeScript example during version synchronization.
2026-08-13 02:00:28 -05:00
Chris Tate a7665807f3 feat(cores): compile TypeScript cores for cross desktop targets (#340)
The external core compile driver now enforces the same host/target
pairing matrix as the service compile lane: same-triple compiles keep
the native lane, Linux and Windows GNU targets cross-compile from any
macOS/Linux/Windows build host over the compiler's zig-cc lane, macOS
targets need a macOS build host, and every refused pairing teaches
before compiler work starts. The co-emitted contract sidecar is
target-independent: a macOS-native, x86_64-windows-gnu, and
x86_64-linux-musl compile of one staged tree emit byte-identical
documents with identical integer-class decisions, and the COFF and ELF
archives declare the same nsc_core_* symbol surface as the Mach-O one.

The SDK's fixture graph compiles corewire for the build host, so the
battery lanes configure under a cross -Dtarget, and a new
stage-cross-e2e step installs the host-fixture, markup, and in-process
service pool batteries under <prefix>/e2e for execution on the target
machine. scripts/cross-e2e.sh drives the lane end to end: it
cross-builds the batteries, the kanban example, and the service fixture
app (in-process carrier — the core and service archives linked into one
executable, with no defined-symbol overlap between the localized
service archive and the core's contract surface) for x86_64-windows-gnu
and x86_64-linux-musl, then executes the batteries on the Windows box
over ssh and in an amd64 Alpine container. gate.sh runs the lane in
either tier when NATIVE_SDK_CROSS=1 is set and skips it otherwise.

All three batteries pass on both targets: 29 host-fixture, 10 markup,
and 19 in-process pool tests on Windows (the three posix-spawn tests
skip there), and 32/10/19 in the musl container. The TypeScript chapter
and ts-core skill state the supported build matrix, including the Linux
glibc spelling and the unchanged desktop-only scope for mobile.
2026-08-13 01:28:42 -05:00
Chris Tate fe92cff10a feat(services): extend the in-process carrier opt-in to Windows and cross targets (#337)
* feat(services): extend the in-process carrier opt-in to Windows and cross targets

scriptc 0.0.28 localizes archive runtime symbols format-aware (ELF,
COFF, Mach-O) on every desktop host and for cross targets, so the
explicit `.service_carrier = "in_process"` opt-in no longer needs the
host-native macOS/Linux gate. Carrier resolution and the SDK fixture
lane's archive gate now admit the compiler's build matrix: Linux and
Windows targets from any macOS/Linux/Windows build host, and macOS
targets from a macOS host. Auto still resolves to the isolated child
carrier everywhere, and child stays selectable on every shape.

The service compile lane replaces its build-host-only refusal with the
same matrix. Same-triple compiles keep the native lane; admitted cross
pairings run the compiler's zig-cc lane (SCRIPTC_CC=zigcc,
SCRIPTC_TARGET=<triple>, and the build's own zig at the front of PATH
through the new --zig-exe argument); refused pairings keep a precise
teaching. The child executable's name follows the target OS instead of
the host's.

Cross Linux targets carry one encoded caveat: a bare `-gnu` spelling
lands on Zig's default glibc floor, which predates arc4random_buf — a
symbol the compiled service runtime references — so carrier resolution
teaches the `-gnu.2.36`+ (or `-musl`) spelling at configure time, and a
stated glibc version now rides the platform triple into the compile.
Windows targets link ws2_32, iphlpapi, and advapi32 beside the compiled
archives: the fixture modules get a shared helper, and the app lane's
Windows platform block adds advapi32 for the archive's CSPRNG.

Both carrier e2e suites pass natively on Windows (14 child-carrier
tests, 19 in-process pool tests: parallel keys, per-key FIFO,
cooperative cancellation and deadlines, trap isolation, streaming, and
replay), a macOS-built x86_64-windows-gnu service archive links and
initializes on Windows, and the linux-musl child executable and archive
cross-compile from macOS. The docs services chapter, packages page, and
ts-services skill state the widened opt-in and the cross-target scope.

* fix(services): align cross-target ScriptC archives

* fix(services): gate in-process archive architectures

* fix(build): keep ScriptC floor checks step-local

* fix(services): tighten cross-target toolchain gates
2026-08-12 23:51:19 -05:00
Railly Hugo 7d67158444 fix(docs): redirect /docs to the introduction page (#338)
/docs returned 404 while every page beneath it resolved and every legacy
URL form already redirected into /docs/*. docsSlugs() only yields nested
slugs, so the /docs segment itself had neither a route nor a generated
redirect, and /docs/ compounded it by 308ing into that dead end.

/docs is the parent of every documentation link on the site and the
likeliest hand-typed entry point, so open it on the Introduction. The
.md sibling keeps the Markdown surface whole for agents that reach for
it, matching the pairing legacyDocsRedirects applies to every slug.

Fixes #331

Co-authored-by: Cris <cristian.correa.cs@gmail.com>
2026-08-13 01:20:52 -03:00
Chris Tate c71a7b4638 Upgrade scriptc to 0.0.28 (#336)
* Upgrade scriptc to 0.0.28

- Pin scriptc and its compiler/runtime lock data to 0.0.28.
- Refresh service contracts, compatibility fixtures, calibration, and documentation.
- Regenerate the service compile-surface reference; the 0.0.28 surface manifest carries no entry changes from 0.0.27.

* docs: state the carrier default correctly on the packages page
2026-08-12 19:56:19 -05:00
Chris Tate d7aeea1ea9 Promote credentials to core effects (#335)
* Promote credentials to core effects

- Add app-scoped TypeScript and Zig credential effects with capability and permission gates plus platform-backed storage.

- Redact credential journal results, synthesize deterministic replay placeholders, and provide hermetic devhost and test stores.

- Update checker diagnostics, mobile plumbing, SDK surfaces, documentation, skills, and conformance coverage.

* fix(runtime): harden credential effect lifecycle

* fix: harden credential effect handling
2026-08-12 19:21:30 -05:00
Chris Tate 8b2a97ffe7 feat(services): add the in-process TypeScript service carrier (#334)
* feat(services): add the in-process TypeScript service carrier

Compile src/services into a thread-instanced, runtime-localized library
archive (scriptc 0.0.27 library mode) linked into the app binary, and run
it on a small worker-thread pool: one archive instance per pool thread,
same-key requests strictly FIFO, distinct keys in parallel across
instances. The carrier preserves the child-process seam exactly — the
same HostCallBinding, poll-based completion delivery, journaled results,
lazy start, and replay that never initializes the archive.

Cancellation and deadlines ride the same cooperative marker-file token
the child publishes, with the same grace; an operation that ignores its
token is abandoned (thread detached, timeout routed, pool refilled), and
a detected trap routes kind service_trap through the per-instance panic
sink while other instances keep answering. Streaming chunks relay live
through a per-request framed file the pool's supervisor thread tails, so
chunks keep preceding the typed terminal mid-operation.

In-process is the default carrier on host-native macOS/Linux builds;
app.zon .service_carrier (and -Dservice-carrier) select explicitly, with
.service_pool_size (-Dservice-pool-size) setting the pool width (default
min(4, cores)). Windows and cross builds keep the child carrier.

corewire grows the two in-process projections (library facade entry and
compiler profile), the service compile script grows an --out-archive
lane, and bench-service-host now measures both carriers. The new pool
e2e suite covers success/throw routing, duplicate and unkeyed keys,
live streaming with cancellation, queued deadlines, parallelism,
per-key FIFO, trap isolation, the registry/archive pairing fence, and
journal replay against the pool.

* test(services): absorb runner load in the pool parallelism proof

One retry of the parallel batch keeps the timing assertion meaningful
on saturated runners; a pool that serialized distinct keys fails both
attempts deterministically.

* fix(services): harden in-process carrier supervision

* fix(services): harden pool queue scheduling

Wake the supervisor when newly admitted work can move the next deadline earlier, and scan the full queue so large busy-key bursts cannot hide runnable independent work.

* fix(services): drain streams after grace race

Let completion-owned requests remain in the supervisor stream polling path when the grace poison CAS loses, and cover the boundary with a deterministic regression.
2026-08-12 16:25:50 -05:00
Chris Tate 473cad71ef feat(storage): ship checked relational SQLite (#326)
* feat(storage): ship checked relational SQLite

- Add capability-gated SQLite effects, migrations, transactions, live queries, and replay across desktop and mobile.
- Validate schemas and named SQL at build time, generating typed command and subscription APIs.
- Add the relational notes flagship, documentation, tests, skills, and SDK mirror support.

* fix relational SQLite correctness gaps

* fix(storage): harden relational SQLite checks

* fix relational runtime policy parity

* fix sqlite tooling on node 22

* fix(storage): harden relational sqlite boundaries

* fix(storage): address relational review findings

* fix(storage): address remaining relational review findings

* fix(storage): address relational review issues

* fix(sqlite): retire stale live queries before replacements
2026-08-12 14:26:00 -05:00
Chris Tate 06b6ccd53c Upgrade scriptc to 0.0.27 (#333)
- Pin scriptc and its compiler/runtime lock data to 0.0.27.
- Refresh service contracts, compatibility fixtures, calibration, and documentation.
- Regenerate the service compile-surface reference; the 0.0.27 surface manifest carries no entry changes from 0.0.26.
2026-08-12 12:41:59 -05:00
Chris Tate ed84e35975 tools: scriptc surface-manifest diff and service-capability gate (#328)
* feat(tooling): gate capability claims on the compiler surface manifest

- Add surface_manifest_diff.mjs: mechanical diff of two scriptc surface
  manifests keyed by stable entry ids — flips to static (with the
  staging-transform/caveat retirements each flip makes due), tier
  regressions, easings, added/removed entries, SC-code and semantic
  note changes; human-readable and --json output.
- Add gen_service_surface.mjs: generate the author-facing service
  compile-surface reference (skill-data/ts-services/references/
  service-surface.md) byte-derived from the pinned compiler's manifest,
  plus a --check mode that fails on a stale or hand-edited reference,
  a manifest/pin version mismatch, any hand-written SC-code claim in
  docs/skills/skill-data prose, or a compiler version literal that does
  not match the pin.
- Wire the check into scripts/gate.sh as the surface-claims step:
  fast tier when docs/, skills/, skill-data/, or packages/core/ change;
  always in full.
- Point skill-data/ts-services/SKILL.md at the generated reference and
  cover the tooling in packages/core/test/surface_tools.test.ts.

* fix(tooling): harden service surface checks

* fix(tooling): close service surface audit gaps

* fix(tooling): close manifest review gaps

* fix(tooling): harden surface claim auditing

* fix(tooling): keep surface audits layer-aware
2026-08-12 09:50:03 -05:00
Chris Tate 5fa8074f7c feat(bench): add service-host carrier benchmark (#329)
Measure the out-of-process TypeScript service carrier through its
production HostCallBinding against a bytes-echo service compiled by the
production service lane (frontend contract -> corewire host/registry ->
exact-pinned plain-scriptc executable). The echo operation returns its
request unchanged, so the numbers are the carrier's — lazy child spawn,
hello fence, framing, pipes, worker-thread queueing — not a workload's.

Scenarios: cold start (fresh host, first keyed request from admission to
polled completion, including the lazy spawn), warm round trips for 64 B
and 256 KiB payloads (p50/p90/p99), and queued throughput for N keyed
requests drained through the single worker thread.

Run: zig build bench-service-host -Doptimize=ReleaseFast
(requires node and npm ci in packages/core, like the services e2e lane).
2026-08-12 01:43:40 -05:00
Chris Tate 8d7946edbd docs: add the TypeScript Services chapter (#327)
* docs: add the TypeScript Services chapter

Give TypeScript services their own documentation page covering the
two-tier split, service authority and the sanitized environment,
operation authoring and kind-tagged errors, the generated typed client
with keys/timeouts/cancellation/streaming, exact vendored npm with the
calibrated coverage results, and the runtime behavior (lazy start,
supervision, replay without the service child, desktop scope).

Condense the inline services section on the TypeScript Cores page to a
summary that links the chapter, point the packages page at the new
route, wire navigation and page titles, and correct the keyed-effect
note: service requests reject duplicate live keys rather than
replacing them.

* docs: restyle the TypeScript Services chapter as reference documentation

Rewrite the chapter in a terse reference style: noun-phrase sections,
short declarative paragraphs, code examples and tables first. All
technical content is unchanged: the two tiers, service authority and
the environment allowlist, operation rules and boundary types,
kind-tagged errors, the generated client, keys/timeouts/cancellation,
streaming, exact vendored npm with the calibration results, runtime
behavior, the dev loop, and the NS1065-NS1067 teachings.

Replace vague failure-mode wording on the cores page with the precise
outcomes: dropped requests dispatch no message, stream cancellation
routes cancelled to err, and truncated streaming fetches end with
err: truncated.
2026-08-12 01:32:56 -05:00
Chris Tate 011caa9183 Upgrade scriptc to 0.0.26 (#325)
- Pin scriptc and its compiler/runtime lock data to 0.0.26.
- Refresh service contracts, compatibility fixtures, calibration, and documentation.
- Invalidate cached service frontends whenever the compiler manifest changes.
2026-08-11 22:38:24 -05:00
Chris Tate b7c493bab0 feat(ts-services): add typed boundary and ecosystem (#321)
* feat(ts-services): add typed boundary and ecosystem

- generate record-typed service clients/codecs and enforce hermetic 100% npm-static coverage

- run services in the devhost with native-format record/replay parity

- add typed streaming, cooperative cancellation, deadlines, fixtures, and guidance

* fix(ts-services): harden request admission

* fix(ts-services): align devhost runtime behavior

* fix(ts-services): enforce request deadlines
2026-08-11 21:25:48 -05:00
Chris Tate aa7ed9aa52 feat(storage): add SQLite-backed record store (#320)
* feat(storage): add SQLite-backed record store

- add capability-shed SQLite storage with deterministic effects, replay, and atomic record operations
- expose matching TypeScript and Zig APIs across desktop and mobile hosts
- add hermetic coverage, devhost support, documentation, and a worked example

Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com>

* fix(storage): restore pristine SQLite amalgamation

- restore the upstream byte removed during whitespace cleanup so the vendored source matches its documented checksum

Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com>

* fix(storage): harden store result delivery

* fix(storage): align devhost store semantics

* fix(storage): address review findings

---------

Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com>
2026-08-11 19:59:33 -05:00
Chris Tate 833e79e44a Implement the TypeScript service seam (#317)
* Implement the TypeScript service seam

- Classify src/services as ordinary static-tier TypeScript and generate a checked service contract.
- Compile, package, and supervise a pinned out-of-process service host behind Cmd.request.
- Cover service authority, failures, restart, timeout, replay, docs, skills, and showcase fixtures.

* fix service boundary validation and staging

* fix TypeScript service packaging and validation

* fix TypeScript service review findings
2026-08-11 14:23:22 -05:00
Chris Tate cafbf206e8 Implement model persistence (#316)
* Implement model persistence

- Add atomic engine-owned snapshots with generated codecs, restore and migration routes, backup recovery, debounce, and replay support.
- Gate persistence through app manifests and native check while keeping the TypeScript, Zig, and devhost surfaces in parity.
- Add store and end-to-end coverage, documentation, and a persisted TypeScript example.

Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com>

* Address model persistence review findings

* Address remaining model persistence review findings

* Fix persistence identity and restore route checks

* fix persistence replay and rollback safety

---------

Co-authored-by: carvalab <1446654+carvalab@users.noreply.github.com>
2026-08-11 13:01:01 -05:00
Chris Tate 0126d20f30 feat(ts): expose platform service commands (#315)
* feat(ts): expose platform service commands

- Add first-class Cmd APIs for credentials, external URLs, path reveal, and local-time formatting.

- Route commands through validated runtime services and native macOS, Linux, and Windows backends.

- Document effect semantics and cover TypeScript encoding, platform behavior, and runtime delivery.

* fix(windows): apply timestamp DST rules

* fix(platform): align local time formatting
2026-08-11 08:46:23 -05:00
Chris Tate 2fd7c4c3dc Add macOS lifecycle hooks for TypeScript apps (#314)
* Add macOS lifecycle hooks for TypeScript apps

- Expose hidden-window, fullscreen, Dock-presence, and launch-at-login controls through manifests and TypeScript effects.
- Implement AppKit and SMAppService behavior across both macOS hosts with safe cross-platform fallbacks.
- Cover the new wire/runtime paths with tests and document the menu-bar app migration workflow.

* fix: address platform lifecycle review findings

* fix: harden macOS lifecycle hooks

* fix: preserve macOS activation behavior

* fix: preserve hidden startup window state
2026-08-11 00:04:47 -05:00
Chris Tate 4c95b04539 Improve retained desktop frame performance (#313)
* Improve retained desktop frame performance

1. Fix retained animation pumping and Windows frame wake scheduling.

2. Make latency and frame profiling monotonic, observable, and regression-tested.

3. Add physical-display-aware macOS and Windows performance gates.

* Fix PR performance and package checks

- Align NativeSdkViewInfo declarations with runtime GPU telemetry.

- Calibrate hosted macOS animation budgets without weakening physical-device defaults.

* Harden Windows performance sampling and shutdown

- Correlate physical hover samples with their responding visual frames.

- Stop due-frame callbacks immediately when the Windows host exits.
2026-08-10 21:22:03 -05:00
Chris Tate c7e64b647c feat: add dynamic TypeScript menu bar items (#311)
* feat: add dynamic TypeScript menu bar items

- Drive status-item presentation and rich menus from committed TypeScript model state.
- Render and route dynamic status items through the native platform hosts.
- Add UTF-8 byte encoding, strict ASCII diagnostics, docs, examples, and end-to-end coverage.

* fix tray menu platform parity

* fix dynamic tray menu behavior

* fix: preserve dynamic tray platform state

* fix tray presentation and command routing
2026-08-10 19:33:42 -05:00
Chris Tate a404ca166d chore(release): prepare v0.8.4 (#310)
- Add v0.8.4 release notes and contributor credits.
- Synchronize CLI, core, platform, tool, and example version pins.
2026-08-10 13:26:30 -05:00
Chris Tate 7f6830a15b Polish component gallery interactions (#308)
* Polish component gallery interactions

- Fix keyboard navigation, scrolling, and focus-visible behavior across interactive canvas widgets.
- Rebuild gpu-components as an isolated TypeScript and Native markup gallery with complete interactive specimens.
- Add live model-driven Default and Geist theme switching with validation, documentation, and smoke coverage.

* fix component gallery focus and controls

* Fix TypeScript setup for component smoke CI
2026-08-10 12:32:31 -05:00
Chris Tate 7a29661384 Fix TS core PTY event key contract (#307)
- Align echoed PTY keys with the byte-text host and facade contract.
- Regenerate SDK declarations and update PTY conformance coverage.
- Compile the PTY event arm through the external-core E2E fixture.

Co-authored-by: NyxTools-M <243123737+NyxTools-M@users.noreply.github.com>
2026-08-10 10:24:13 -05:00
Chris Tate a727b1db68 Add customizable macOS DMG packaging (#304)
* Add customizable macOS DMG packaging

- Build polished drag-to-Applications archives with Retina-aware generated or custom backgrounds.

- Add manifest controls for Finder geometry, positioned items, files, directories, and links.

- Correct default optical alignment and document the packaging workflow.

* Fix DMG package validation gaps

* fix(packaging): make DMG generation reliable

* Validate TIFF payload ranges

* fix: tighten dmg validation and staging
2026-08-10 01:00:54 -05:00
Chris Tate 7e3a3157d0 feat(core): support streaming fetch responses (#300)
* feat(core): support streaming fetch responses

- Add a typed line-streaming Cmd.fetch overload and carry it through the command wire and runtime host.

- Keep stream lifecycle deterministic with loud cancellation and duplicate-key rejection.

- Cover the feature with contract, conformance, runtime, compiled-core, harness, example, and documentation updates.

* feat: stream AI chat through Vercel gateway

- Render chat-completion SSE deltas as they arrive.
- Pin the example to Vercel AI Gateway with official key config.
- Cover streaming, failure, and replay paths end to end.

* feat: add streaming fetch and chatbot example

* feat(chatbot): refine streaming chat experience

- Add a compact live model picker and immediate Stop action.

- Improve conversation layout, prompt focus, and caret retention.

- Expand chatbot documentation and end-to-end regression coverage.

* fix: harden streaming fetch limits

* fix: harden streaming fetch and textarea behavior

* fix(canvas): render lifted rich text
2026-08-09 21:02:29 -05:00
Chris Tate 283ab804c0 Add cross-platform audio capture and voice memo example (#303)
* Add cross-platform audio capture and voice memo example

- Add microphone and system-audio capture effects for macOS, Windows, and TypeScript core.
- Add a polished voice memo example with private app-data WAV saving and playback.
- Document platform support and packaging requirements with runtime and conformance coverage.

Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com>

* Fix audio capture teardown and permissions

Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com>

* Fix audio capture lifecycle and metadata

Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com>

* Fix audio capture lifecycle edge cases

Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com>

* fix: harden audio capture lifecycle

Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com>

---------

Co-authored-by: Marcus Schiesser <17126+marcusschiesser@users.noreply.github.com>
2026-08-09 17:09:23 -05:00
Chris Tate 83a7aee721 fix(macos): smooth dialog backdrop blur (#299)
* fix(macos): smooth dialog backdrop blur

- Replace the flat box blur with an optimized three-pass Gaussian approximation.

- Preserve shadcn-compatible blur and scrim values while covering the host path with a build check.

* Fix macOS backdrop blur invalidation
2026-08-08 20:11:39 -05:00
Chris Tate 30c1410c10 chore(release): prepare v0.8.3 (#298)
- Add release notes for packaged assets, drag landing polish, and the expanded Kanban showcase.
- Synchronize CLI, core, platform package, and TypeScript example versions.
2026-08-08 17:26:42 -05:00
Chris Tate bfcc5ff8df fix(kanban): polish packaged board interactions (#297)
- Resolve boot image assets from packaged macOS bundle resources so agent avatars render after launch.
- Keep drag landing motion above swimlane clips while preserving clipped neighbor reflow.
- Double the seeded Jira-style tickets and remove issue glyphs from card metadata.
2026-08-08 16:41:23 -05:00
Chris Tate 3a580c9a8d chore: prepare v0.8.2 release (#296)
- Bump CLI, core, platform packages, and TypeScript examples to v0.8.2.
- Add complete release notes and contributors for the v0.8.2 range.
- Keep the Kanban core pin covered by release version sync and validation.
2026-08-08 14:44:47 -05:00
Chris Tate 4269233703 feat(examples): make kanban an agent ticket board (#295)
* feat(examples): make kanban an agent ticket board

- Add numbered agent tickets with OpenAI and Claude avatars from SVGL.
- Simplify the titlebar to an icon-only add action.
- Keep drag geometry and end-to-end coverage aligned.

* fix(examples): make kanban columns scrollable
2026-08-08 14:26:34 -05:00
Chris Tate b230b140b8 feat(core): add native drag and drop to TypeScript apps (#285)
* feat(core): add native drag and drop to TypeScript apps

- Carry native file drops and widget drag events through the TypeScript core contract.
- Rebuild Kanban in TypeScript with animated reordering, cross-column moves, and Escape cancellation.
- Add runtime, ABI, rendering, end-to-end, documentation, and example coverage.

Co-authored-by: John Lindquist <36073+johnlindquist@users.noreply.github.com>

* fix: correct canvas drag lifecycle

* fix: harden canvas drag dispatch

* fix drag lifecycle and preview rendering

* fix(runtime): preserve drag gesture arbitration

---------

Co-authored-by: John Lindquist <36073+johnlindquist@users.noreply.github.com>
2026-08-08 13:22:16 -05:00
Railly Hugo 04125b7d62 fix(canvas): preserve explicit zero padding (#288) 2026-08-08 14:41:13 -03:00
Chris Tate 21f6057041 Make TypeScript the clear authoring default (#284)
* Make TypeScript the clear authoring default

- Route agents to TypeScript cores and Native markup for new apps.
- Put TypeScript examples and code first across human-facing entry points.
- Keep Zig clearly documented as an explicit alternative and extension tier.

* Clarify TypeScript authoring guidance

* docs: clarify TypeScript native UI guidance
2026-08-04 22:15:13 -05:00
Chris Tate 4f0b57f2c2 feat(effects): add desktop notifications (#283)
* feat(effects): add desktop notifications

- Expose fire-and-forget notifications through the UiApp effects facade.
- Validate requests and suppress external alerts during fake execution and replay.
- Add focused runtime coverage and document the native Zig API.

* feat(ts-core): add desktop notifications

- Expose fire-and-forget notifications through Cmd.showNotification.
- Route the additive command wire opcode through the native platform effect.
- Cover compiled-core delivery and document the primary authoring path.
2026-08-04 20:14:54 -05:00
Chris Tate b21849c1bf chore: prepare v0.8.1 release (#282)
- Synchronize CLI, core, platform, and example package versions to 0.8.1.
- Add complete release notes for Markdown HTML, image handling, and payload-free HTTP writes.
- Record release-process improvements and contributor attribution.
2026-08-04 19:17:38 -05:00
595 changed files with 357799 additions and 10745 deletions
+29 -8
View File
@@ -20,7 +20,7 @@ jobs:
version: 0.16.0
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
# TypeScript cores compile through the external core compiler at
# build/test time; the compiler and the frontend's toolchain both
# arrive with this one install (without it the ts-core suites skip
@@ -29,6 +29,8 @@ jobs:
# per-run compiler cache dies with the VM and runs stay hermetic
# across commits by machine lifecycle.
- run: npm ci --prefix packages/core
- name: Service surface tooling and claims
run: node --test packages/core/test/surface_tools.test.ts
- run: zig build test
- run: zig build validate
@@ -42,7 +44,7 @@ jobs:
version: 0.16.0
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
# The exact-pinned external core compiler and the frontend's
# toolchain, one install (packages/core/package.json is the one
# place the pin lives).
@@ -61,8 +63,20 @@ jobs:
- uses: vercel-labs/setup-zig@v1
with:
version: 0.16.0
- uses: actions/setup-node@v4
with:
node-version: 24
# gpu-components is a TypeScript-core app, so its smoke build needs
# the frontend compiler and exact-pinned TypeScript toolchain.
- run: npm ci --prefix packages/core
# The mobile aggregate runs on Linux for Android. Exercise the other
# store-capable cross-target here against the real iPhone simulator SDK.
- run: zig build test-example-mobile-canvas-lib-ios-store
- run: zig build test-webview-system-link
- run: zig build test-webview-smoke
# The zero-config TypeScript runner must load app.zon menus before
# automation can select their registered command ids.
- run: zig build test-menu-bar-smoke
# Signed-package seal pin: an ad-hoc signed package must pass
# codesign --verify --strict (macOS runners are the only tier with
# codesign; the step skips loudly anywhere else).
@@ -89,8 +103,8 @@ jobs:
with:
version: 0.16.0
# Percentile perf check: 5 cold launches asserting p90
# first-frame latency under NATIVE_SDK_PERF_BUDGET_MS, then 5 steady-state
# widget clicks asserting p90 input latency under NATIVE_SDK_PERF_INPUT_BUDGET_MS.
# first-frame latency, 5 steady-state widget clicks asserting p90 input
# latency, then a reset-scoped retained-animation cadence sample.
# Its own job so a shared-runner slowdown is visible in isolation and
# never blocks the correctness smokes.
# Shared macos-14 runners are far noisier than a dev box (first CI run
@@ -101,6 +115,13 @@ jobs:
env:
NATIVE_SDK_PERF_BUDGET_MS: "1500"
NATIVE_SDK_PERF_INPUT_BUDGET_MS: "500"
# The virtual display is commonly 25-35 Hz even though AppKit
# reports the window visible. Keep physical/dev defaults at the
# strict 60 Hz-class 45/20/34; this hosted job remains a
# step-function regression sentinel rather than a display gate.
NATIVE_SDK_PERF_ANIMATION_MIN_FRAMES: "30"
NATIVE_SDK_PERF_ANIMATION_P90_MS: "50"
NATIVE_SDK_PERF_ANIMATION_MAX_MS: "100"
linux-webkitgtk:
name: Linux WebKitGTK
@@ -163,7 +184,7 @@ jobs:
version: 0.16.0
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
# The TypeScript core suites compile through the external core
# compiler at build/test time; without the install they skip
# silently, so CI must provide it.
@@ -203,7 +224,7 @@ jobs:
version: 0.16.0
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
# The TypeScript examples compile through the external core
# compiler at build time; the compiler and the frontend toolchain
# arrive with this install.
@@ -337,7 +358,7 @@ jobs:
version: 0.16.0
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
# The scaffold default is the TypeScript core; its frontend and
# compiler run at build time from this checkout's packages/core.
- run: npm ci --prefix packages/core
@@ -442,7 +463,7 @@ jobs:
version: 0.16.0
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
# The default scaffold is a TypeScript core: its build runs the
# @native-sdk/core frontend and the external core compiler from
# this checkout's own install.
+1
View File
@@ -42,6 +42,7 @@ docs/tsconfig.tsbuildinfo
# Dev-tool dependency trees (installed per package, never committed)
packages/core/node_modules/
.pnpm-store/
# The CLI-materialized editor copy of @native-sdk/core inside the TS example
# apps (node_modules is editor surface, never source — the same entry the
+9
View File
@@ -2,6 +2,15 @@
Guidance for agents (and humans) working on this repository.
## App authoring default
Native SDK itself is implemented in Zig, but Native SDK **apps are authored in TypeScript + Native markup by default**. Do not infer the app-authoring language from this repository's implementation language or from older Zig-core examples.
- For a new app, use `native init <path>` and expect `src/core.ts`, `src/app.native`, and `app.zon`. Ordinary compiled TypeScript work that needs filesystem, process, JSON, regex, classes, or other static-tier APIs belongs under optional `src/services/`, reached from the core with `Cmd.request`; do not import a service from the core. Do not add Zig app code unless the user explicitly chooses `--template zig-core` or the feature requires a toolkit extension.
- Before changing an existing app, inspect its tree. A `src/core.ts` app stays TypeScript; a `src/main.zig` app stays Zig unless the task is specifically a migration.
- For default app work, read `skill-data/native-ui/SKILL.md` and `skill-data/ts-core/SKILL.md`; also read `skill-data/ts-services/SKILL.md` when the tree has `src/services/` or the task needs ordinary TypeScript beyond the core subset. `skill-data/core/SKILL.md` covers shared/runtime concerns; `skill-data/zig/SKILL.md` is for Zig-core apps and SDK implementation work.
- The `-ts` suffix on a few examples only distinguishes ports from older Zig originals. New TypeScript apps need no suffix because TypeScript is the default.
## Build, test, and gate
```bash
+200 -3
View File
@@ -2,12 +2,211 @@
All notable changes to the Native SDK (formerly zero-native) will be documented in this file.
## 0.8.0
## 0.9.4
<!-- release:start -->
### New Features
- **Model-driven window restore policies**: TypeScript apps can now declare whether each model-driven window restores saved geometry or opens fresh, including center-on-primary placement, with matching defaults, validation, runtime forwarding, tests, and documentation (#381).
### Improvements
- **Faster, more predictable iterative rebuilds**: Generated core and service ABI artifacts now change only when their contents do, markup and app code compile into independently cached objects, SDK module edits invalidate the right inputs, and rebuild diagnostics expose phase timing, memory use, and cache decisions across platforms (#382).
- **Updated TypeScript compiler integration**: ScriptC advances to 0.0.33 with published compile-cache bootstrapping, explicit development and release library profiles, synchronized compiler-surface artifacts, and Node 24 throughout the TypeScript build and CI toolchain (#384).
### Contributors
- @ctate
<!-- release:end -->
## 0.9.3
### New Features
- **Model-driven TypeScript theme state**: Zero-config TypeScript apps can now derive the built-in pack, color scheme, and accent from committed model state while preserving manifest fallback, live system accessibility settings, deterministic replay, and the existing `themePack` helper (#378).
- **Platform-correct line deletion**: Command+Backspace on macOS now deletes to the start of a field or logical textarea line across every editable canvas control, with matching TypeScript text helpers, controlled-state behavior, undo, and replay (#377).
### Bug Fixes
- **Precise macOS file-drop routing**: AppKit drops now retain labeled canvas and WebView targets with top-left, view-local coordinates, while unlabeled window regions fall back to content coordinates (#374).
- **Manifest menus in generated runners**: Zero-config TypeScript and Zig-core apps now load `app.zon` commands, shortcuts, and menus consistently in live and replay runners, including ejected-runner fallbacks (#376).
- **Large TypeScript message unions compile reliably**: Generated shims now derive comptime scan quotas from message shape and identifier size, allowing wide unions to compile across persistence, channels, environment routing, and the full external-core pipeline (#375).
- **Correct combobox Enter precedence**: A bound `on-submit` now handles Enter before trigger activation, so query submission no longer opens the picker or dispatches the wrong command (#373).
### Contributors
- @ctate
- @MohakBajaj
## 0.9.2
### New Features
- **Flash-free accessory startup**: Apps can opt into accessory activation from `app.zon` to launch without a Dock icon or foreground flash, with tray-affordance validation, runtime composition, packaging support, and an updated menu-bar example (#358).
- **Logical canvas radio groups**: Nested radios now form accessible single-selection groups with roving focus and consistent keyboard, pointer, handler, and naming semantics (#361).
- **Budget-aware photo decoding**: Dynamic encoded images are downsampled across desktop and mobile codecs to fit a configurable registered-pixel budget, with independent source bounds, deterministic replay, and platform-level regression coverage (#366).
### Bug Fixes
- **Correct anchored surfaces**: Floating and modal surfaces now dismiss without requiring focus, relayout after scroll restoration, resolve against the correct root, and behave consistently across window contexts (#363).
- **Reliable autofocus and caret reveal**: Keyboard focus, autofocus, and automation now transactionally reveal offscreen targets while preserving collapsed end-caret selections in text editors (#364).
- **Explicit link decoration**: Linked text spans now honor their underline flag while Markdown-generated links retain conventional underlines (#368).
- **Stable macOS window geometry**: Fresh windows now distinguish restored, explicit, and default placement, while AppKit and CEF frame events consistently report content geometry without titlebar drift (#369, #370).
### Improvements
- **Consistent canvas controls and surfaces**: Checkbox and radio labels can contain markup consistently, while actionable states, disabled colors, variant accents, selection geometry, compact layouts, and zero-width strokes now render uniformly across the schema, runtime, accessibility tree, and documentation (#367).
### Contributors
- @ctate
- @sepehr-safari
## 0.9.1
### New Features
- **Multi-item macOS menu bars**: Apps can now manage independent, keyed status items with model-driven updates, events, automation, journaling, and regression coverage (#343).
- **Complete TypeScript file effects**: Secure, permission-gated effects now support bounded streaming reads, atomic writes, stat, append, and deletion while preserving deterministic record and replay behavior (#339, #350).
- **Actionable desktop notifications**: Notification replacement identifiers and actions dispatch through the ordinary command path on macOS, Windows, and Linux (#347).
- **Secondary-window lifecycle control**: Window descriptors can declare quit or hide-on-close behavior, preserve hidden-window identity when reopened, and expose the same model-driven window contract to TypeScript apps (#349, #351).
- **Mobile TypeScript cores and services**: TypeScript apps with services now compile into iOS and Android library archives, with mobile packaging and device-level runtime coverage (#346).
### Bug Fixes
- **Safe Linux alert dialogs**: GTK alert dialogs now initialize with a valid empty format string, avoiding a crash from a null constructor argument (#354).
- **Correct compiled-core tuple returns**: The SDK now pins the scriptc tuple-normalization fix and verifies bare-model and effect-tuple ABI returns with a compiled-core regression (#356).
### Improvements
- **Stronger TypeScript core guidance and diagnostics**: Subset rules now distinguish permanent guarantees from deliberately deferred capabilities and point authors to the appropriate service alternative (#345).
- **End-to-end services showcase**: The Feed Reader example now demonstrates the full TypeScript service workflow with typed feed parsing, shared data, fixtures, and replay coverage (#352).
- **Updated compiler integration**: scriptc advances through 0.0.31 with refreshed generated contracts, compatibility fixtures, and compiler-surface references (#344, #356).
### Contributors
- @ctate
- @ElSebas41
- @johnlindquist
## 0.9.0
### New Features
- **Ordinary TypeScript services behind a typed boundary**: Apps can place filesystem, process, JSON, regex, class, and exact-vendored npm work under `src/services/`; Native SDK generates the checked client and codecs, compiles a pinned static service executable, and carries keyed requests, typed streaming, cooperative cancellation, deadlines, supervision, and deterministic replay across the isolated boundary (#317, #321).
- **Optional in-process TypeScript services**: Services can use the same boundary through a linked, runtime-localized worker pool with per-key FIFO ordering, parallel independent keys, streaming, timeout and trap isolation, and replay that never starts the carrier; the explicit opt-in now follows the compiler's Windows, Linux, macOS, and cross-target matrix while the isolated child remains the automatic default (#334, #337).
- **Engine-owned model persistence**: TypeScript cores can persist committed models through capability-gated, atomically replaced snapshots with generated codecs, debounced writes, backup recovery, explicit restore and migration routes, rollback safety, and journal/replay support (#316).
- **SQLite record storage**: TypeScript and Zig apps can use a capability-gated record store for deterministic atomic CRUD effects backed by bundled SQLite across desktop and mobile hosts, with devhost parity and a complete Record Store example (#320).
- **Checked relational SQLite**: Append-only migrations, build-validated named SQL, transactions, generated typed commands and live-query subscriptions, replay, and the Relational Notes example make relational SQLite a first-class offline data layer across desktop and mobile (#326).
- **Model-driven menu-bar apps**: TypeScript apps can derive status-item labels, icons, tooltips, and rich menus from committed model state, while new macOS effects control hidden startup, fullscreen, Dock visibility, and launch-at-login behavior across both native hosts (#311, #314).
- **Platform services for TypeScript cores**: Typed effects now open external URLs, reveal filesystem paths, and format local time through validated macOS, Linux, and Windows backends (#315).
- **App-scoped credentials**: TypeScript and Zig cores can store, load, and delete credentials through capability- and permission-gated native providers, with redacted journals, deterministic replay placeholders, and hermetic devhost stores across desktop and mobile (#335).
- **Cross-compiled TypeScript cores**: The external core compiler now builds Linux and Windows GNU targets from macOS, Linux, or Windows and macOS targets from macOS, with target-independent contracts and cross-platform end-to-end batteries for Windows and Linux musl (#340).
### Bug Fixes
- **Working documentation root**: `/docs`, `/docs/`, and the matching Markdown route now resolve to the Introduction instead of ending at a 404 (#338).
### Improvements
- **Faster retained desktop frames**: Animation pumping and Windows wake scheduling now avoid stalled or redundant work, profiling uses monotonic frame-correlated telemetry, and physical macOS and Windows performance gates protect input latency and frame budgets (#313).
- **Measured, compiler-truth service tooling**: A dedicated TypeScript Services reference documents the two-tier model and failure semantics; production-carrier benchmarks measure cold start, latency, and throughput; generated compiler-surface references and manifest diffs keep capability claims mechanically honest; and scriptc advances through 0.0.28 with refreshed contracts and calibration (#325, #327, #328, #329, #333, #336).
### Contributors
- @ctate
- @carvalab
- @Railly
- @camilocbarrera
## 0.8.4
### New Features
- **Streaming fetch responses for TypeScript cores**: `Cmd.fetch` can now deliver line-framed HTTP responses through typed message arms with deterministic terminal errors, loud cancellation, duplicate-key rejection, and bounded line sizes; the rebuilt Chatbot example streams Vercel AI Gateway replies with live model selection and a Stop action (#300).
- **Desktop audio capture**: TypeScript cores can start bounded, timestamped microphone or system-output PCM streams on macOS and Windows with explicit lifecycle, permission, drop-count, and replay handling; the new Voice Memo example records, saves, and plays WAV files (#303).
- **Customizable macOS DMG packaging**: `native package` now creates polished drag-to-Applications disk images with generated or custom Retina backgrounds, configurable Finder geometry, positioned app and Applications entries, and staged files, directories, or links (#304).
- **Live TypeScript theme packs**: zero-config TypeScript apps can export `themePack(model)` to switch the built-in theme pack from app state without losing live system scheme, accessibility, accent, or scale inputs (#308).
### Bug Fixes
- **Smooth macOS dialog blur**: Host backdrop blur now uses an optimized three-pass Gaussian approximation and correct dirty-region invalidation, eliminating flat or stale dialog backgrounds while preserving the established scrim treatment (#299).
- **Byte-accurate PTY event keys**: TypeScript PTY event routes now expose echoed session keys as `Uint8Array`, matching the byte-text host, generated facade, and external-core contract (#307).
- **Reliable keyboard widget navigation**: Interactive canvas lists, trees, menus, and anchored controls now retain logical focus across clipped rows, scroll keyboard targets into view, and paint active and focus-visible states consistently (#308).
### Improvements
- **TypeScript component gallery**: The GPU component showcase is now a TypeScript core and Native markup app with isolated interactive specimens, model-driven Default and Geist switching, clearer navigation, and dedicated smoke coverage (#308).
### Contributors
- @ctate
- @marcusschiesser
- @NyxTools-M
## 0.8.3
### Bug Fixes
- **Packaged TypeScript app assets**: Runtime asset lookup now finds bundled macOS resources before the process working directory, so Native markup boot images such as the Kanban agent avatars render after launch (#297).
- **Unclipped drag landing motion**: Dropped cards now stay in the lifted drag layer through their landing animation while neighboring reflow remains clipped within its swimlane (#297).
### Improvements
- **Denser Kanban showcase**: The seeded board now includes twice as many Jira-style tickets and removes redundant issue glyphs from card metadata (#297).
### Contributors
- @ctate
## 0.8.2
### New Features
- **Native drag and drop for TypeScript apps**: Native markup's new `on-drag` channel delivers live, release, and cancellation geometry to compiled cores while the renderer lifts the source under the pointer, preserves one globally keyed insertion slot, animates neighboring items, and supports Escape cancellation; TypeScript cores can also map native multi-file drops into ordinary deterministic messages through `dropMsg` (#285).
- **Desktop notifications from model cores**: TypeScript apps can return fire-and-forget `Cmd.showNotification` effects and Zig apps can call `fx.showNotification`, with bounded validation and suppression during fake execution and session replay (#283).
### Bug Fixes
- **Explicit zero canvas padding**: Programmatic and compiled or interpreted Native markup views now preserve `padding="0"` instead of replacing it with the widget kind's default padding (#288).
### Improvements
- **TypeScript-first app authoring guidance**: Repository instructions, bundled skills, examples, package documentation, and the docs site now consistently lead with TypeScript cores and Native markup for new apps while keeping Zig as the explicit alternative and toolkit-extension tier (#284).
- **Agent ticket Kanban showcase**: The TypeScript Kanban example now presents numbered OpenAI- and Claude-assigned tickets, uses an icon-only add action, keeps columns scrollable, and extends its end-to-end coverage for the updated drag geometry (#295).
### Contributors
- @ctate
- @johnlindquist
- @Railly
## 0.8.1
### New Features
- **Safe presentational HTML in Markdown**: Markdown now lowers common GitHub-style inline and block HTML into native widgets, including links, details, aligned containers, and caller-resolved images, while scripts, styles, forms, embeds, event attributes, and unsupported or malformed markup remain inert literal text (#280).
### Bug Fixes
- **Reliable resolved Markdown images**: image discovery now follows renderable block starts, canonicalizes entity-encoded URLs consistently between loading and lookup, preserves aspect ratios within declared bounds, honors centered and end alignment, and ignores images inside comments, unsupported markup, code, and preformatted blocks (#281).
- **Payload-free HTTP write requests**: `Effects.fetch` now sends an explicit zero-length body for POST, PUT, and PATCH requests without a payload, preventing debug-build crashes and emitting the required `Content-Length: 0` header (#277).
### Improvements
- **History-driven release notes**: release preparation now builds the complete changelog entry and contributor list from the commits since the previous release, replacing the per-change fragment workflow (#278).
### Contributors
- @ctate
- @Railly
## 0.8.0
### New Features
- **Compiler-truth checks for TypeScript cores**: `native check` now ends with the pinned external core compiler's analyzer over the entry with the shipped SDK declarations mapped, so check and build share one compiler verdict. Type errors the frontend's own line would miss fail with the compiler's diagnostics verbatim; an analyzer that cannot reach a verdict defers to the build instead of wedging check.
- **TypeScript cores compile through the external core compiler**: the frontend checks `src/core.ts` and emits its contract sidecar, the exact-pinned compiler builds a native archive, and the app links a generated mirror over it — no JS runtime in the binary, nothing to configure.
- **The TS-to-Zig transpiled lane is removed** (a deliberate pre-1.0 break): `core_compiler = "transpiler"` in app.zon (and `-Dcore-compiler=transpiler`) is refused with a teaching, and `native check` runs the checker and contract only — no emitted Zig lands under `.native/check/`.
@@ -24,8 +223,6 @@ All notable changes to the Native SDK (formerly zero-native) will be documented
- @ctate
<!-- release:end -->
## 0.7.2
### New Features
+5 -2
View File
@@ -93,17 +93,20 @@ Read the full guide at [native-sdk.dev/quick-start](https://native-sdk.dev/quick
## Examples
The apps pictured above live in [examples/](./examples), most as zero-config projects — `app.zon` plus `src/`, no build files — run straight from their directory with `native dev`.
The apps pictured above live in [examples/](./examples), most as zero-config projects — `app.zon` plus `src/`, no build files — run straight from their directory with `native dev`. Start with the TypeScript examples when learning the primary authoring path. The `-ts` suffix on `soundboard-ts` and `system-monitor-ts` is historical because those apps are ports kept beside older Zig originals. Chatbot is TypeScript-only and follows the unsuffixed naming used by new apps created with `native init`.
| Example | What it shows |
| --- | --- |
| [`chatbot`](./examples/chatbot) | TypeScript + Native markup end to end: modules, a text editor, streaming fetch effects, and replay-safe configuration. |
| [`soundboard-ts`](./examples/soundboard-ts) | The full music-player showcase in TypeScript + Native markup: audio, search, assets, timers, and context menus. |
| [`system-monitor-ts`](./examples/system-monitor-ts) | A live process monitor in TypeScript + Native markup: subprocess effects, tables, charts, and timers. |
| [`calculator`](./examples/calculator) | A complete small app: markup keypad, keyboard input, chrome shortcuts, theming. |
| [`notes`](./examples/notes) | Persistence through the effects channel: debounced writes, restore on boot, dialogs, search. |
| [`soundboard`](./examples/soundboard) | Album grid with decoded cover art, context menus, timers, and a custom theme. |
| [`deck`](./examples/deck) | The soundboard player rebuilt as a dense hardware chassis: two windows, same widgets, different tokens. |
| [`feed`](./examples/feed) | A 100,000-row list, virtualized with runtime-owned scrolling. |
The full catalog in [examples/README.md](./examples/README.md) also covers guarded OS capabilities, GPU surfaces, WebView composition, web-frontend shells, and the iOS/Android embed hosts.
The unsuffixed showcase apps above predate the TypeScript default and retain their Zig cores as first-class alternative implementations. The full catalog in [examples/README.md](./examples/README.md) also covers guarded OS capabilities, GPU surfaces, WebView composition, web-frontend shells, and the iOS/Android embed hosts.
## Platforms
+1089 -404
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -27,6 +27,7 @@
"src",
"templates",
"tests",
"third_party/sqlite",
"tools",
},
}
+1501 -97
View File
File diff suppressed because it is too large Load Diff
+15 -18
View File
@@ -12,21 +12,16 @@
// node's ancestor node_modules walk). Node's own type stripping is never
// relied on: it refuses node_modules-resident .ts by design
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), and outside node_modules
// it only became DEFAULT in node 22.18 — so a checkout target on
// 22.15-22.17 would die with ERR_UNKNOWN_FILE_EXTENSION if the hook let it
// "fall through". Hooking everything makes the 22.15 floor true for both
// layouts. Then the runner imports the requested module with argv
// it only became DEFAULT in node 22.18. Hooking everything keeps both layouts
// identical. scriptc 0.0.33 requires Node 24 for its published compile-cache
// bootstrap, so this shared runner enforces the same floor before importing
// any frontend module. Then it imports the requested module with argv
// respliced so the target sees its usual shape (its own path at argv[1],
// its arguments from argv[2]).
//
// On node builds without module.registerHooks (pre-22.15, or 23.0-23.4 —
// the hook landed in 22.15 and 23.5, so ">=22.15" alone is not the
// capability line) NO .ts target can run — node_modules-resident
// stripping is refused by design and default stripping outside
// node_modules only landed in 22.18, which is above this tier anyway —
// so the runner fails fast with one teaching line (upgrade to Node.js
// 22.15+, on the 23 line 23.5+) before importing it, instead of
// surfacing node's raw extension/stripping error.
// A Node 24 build without module.registerHooks is incomplete for this tier,
// so the runner gives the same Node 24 teaching instead of surfacing a raw
// extension/stripping error.
import module, { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
@@ -39,19 +34,21 @@ if (!target) {
process.exit(2);
}
const targetPath = resolve(target);
const nodeMajor = Number(process.versions.node.split('.')[0]);
if (!Number.isInteger(nodeMajor) || nodeMajor < 24) {
console.error(`TypeScript apps need Node.js 24+; you're running ${process.version} - upgrade node and re-run.`);
process.exit(1);
}
// Drop the runner from argv so the target module parses its own argv
// exactly as when node runs it directly.
process.argv.splice(1, 1);
if (typeof module.registerHooks !== 'function') {
// No load hooks on this node (pre-22.15, or a 23.0-23.4 build). Every
// .ts target needs the hook — node_modules-resident stripping is
// refused by design, and native default stripping outside node_modules
// is 22.18+ — so any .ts target would fail deep inside node with a raw
// extension/stripping error. Teach the fix instead.
// Every .ts target needs the hook. A supported Node build without it is
// incomplete for this tier, so teach the supported installation instead.
if (targetPath.endsWith('.ts')) {
console.error(
`TypeScript apps need Node.js 22.15+ (on the 23 line: 23.5+); you're running ${process.version} - upgrade node and re-run.`,
`TypeScript apps need Node.js 24+ with module.registerHooks; you're running ${process.version} - upgrade or reinstall node and re-run.`,
);
process.exit(1);
}
+1
View File
@@ -4,3 +4,4 @@ next-env.d.ts
.next-gate/
.next-agent/
.next-check/
.next-final/
+7
View File
@@ -58,6 +58,13 @@ const nextConfig = {
return [
// The Philosophy page became the Introduction, the opening page of the docs.
{ source: "/philosophy", destination: "/docs/introduction", permanent: true },
// docsSlugs() only yields nested slugs, so the /docs segment itself has
// neither a route nor a generated redirect and 404s. It is the parent of
// every documentation link on the site and the likeliest hand-typed entry
// point, so open it on the Introduction instead. The .md sibling keeps the
// Markdown surface whole for agents that reach for it.
{ source: "/docs", destination: "/docs/introduction", permanent: true },
{ source: "/docs.md", destination: "/docs/introduction.md", permanent: true },
...legacyDocsRedirects,
];
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 854 B

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.
+1 -1
View File
@@ -156,7 +156,7 @@ A Zig-root app keeps dev-time hot reload for its embedded fragments too: build w
## 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](/docs/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](/docs/native-ui#effects).
`update` stays pure by routing anything that leaves the model — subprocesses, HTTP, file persistence, timers, clipboard, desktop notifications — through the effects channel, and routed 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](/docs/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](/docs/native-ui#effects).
## Dropping down
+23 -9
View File
@@ -24,8 +24,6 @@ The manifest `native init` generates — identity, one shell window with a GPU s
.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 },
},
@@ -148,13 +146,29 @@ A fuller manifest for an app that also [embeds web content](/docs/frontend) and
<td><code>platforms</code></td>
<td>Target platforms: <code>macos</code>, <code>linux</code>, <code>windows</code></td>
</tr>
<tr>
<td><code>dmg</code></td>
<td>Optional macOS DMG presentation: volume name, PNG/JPEG/TIFF background (with automatic adjacent <code>@2x</code> discovery), usable Finder canvas and icon sizes, simple app/Applications positions, or an explicit positioned <code>items</code> list of the app, Applications alias, project files/directories, and absolute links. The zero-config defaults produce a complete drag-to-Applications layout.</td>
</tr>
<tr>
<td><code>permissions</code></td>
<td>Runtime permissions (see <a href="/docs/security">Security</a>)</td>
<td>Runtime permissions (see <a href="/docs/security">Security</a>). Audio capture uses <code>microphone</code> and <code>system_audio</code>; macOS packaging emits the matching microphone, audio-capture, and screen-capture usage descriptions only when declared</td>
</tr>
<tr>
<td><code>capabilities</code></td>
<td>Feature declarations (see <a href="/docs/security">Security</a>)</td>
<td>Feature declarations (see <a href="/docs/security">Security</a>). <code>"store"</code> links the engine-owned record store; <code>"sqlite"</code> links relational SQL effects. They share one capability-shed SQLite object but use separate databases; see <a href="/docs/record-store">Record Store</a> and <a href="/docs/sqlite">Relational SQLite</a></td>
</tr>
<tr>
<td><code>dock_visible</code></td>
<td>Initial macOS Dock and app-switcher presence. Defaults to <code>true</code>. Set <code>false</code> for an Accessory/menu-bar app; the policy is applied before startup windows are created, so no Dock tile flashes. Accessory apps must declare the <code>"tray"</code> capability. Runtime <code>Cmd.setDockPresence</code> can still promote or demote the app later.</td>
</tr>
<tr>
<td><code>persist</code></td>
<td>Engine-owned Model snapshot config: monotonic <code>version</code>, optional <code>debounce_ms</code>, and the <code>restore</code> Msg routes (<code>ok</code>/<code>none</code>/<code>err</code>). Requires <code>"persist"</code> in capabilities — see <a href="/docs/persistence">Model Persistence</a></td>
</tr>
<tr>
<td><code>images</code></td>
<td>Optional registered-image budget: <code>.images = .&#123; .max_image_pixel_bytes = 8_388_608 &#125;</code>. The default is 1 MiB and accepted values are 18 MiB. Encoded photos decode aspect-preservingly to fit; storage is lazy per used slot, but 16 fully used 8 MiB slots are a declared 128 MiB high-water. See <a href="/docs/dynamic-images">Dynamic Images</a>.</td>
</tr>
<tr>
<td><code>bridge</code></td>
@@ -269,9 +283,9 @@ For a scene-first app — a `UiApp` passing its Zig scene (`shell_scene`) to the
},
```
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="/docs/native-ui">Native UI</a>). Platforms without the concept keep standard chrome. The same `titlebar` field is accepted on top-level `windows` entries.
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`), `initially_hidden` (default false; create the native window ordered out until an explicit show/focus), `allows_fullscreen` (default true; false disables native fullscreen on macOS without disabling ordinary resizing), `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`). `restore_state` defaults to true and controls only whether the state store is consulted: a store hit restores the saved frame, an authored `x` or `y` is explicit placement, and a fresh window with neither is default placement. On macOS the default `clamp_to_visible_screen` policy keeps restored and explicit frames on their matching or nearest display, centers the main fresh window, and cascades fresh secondary windows; `center_on_primary` centers restored and fresh default frames on the primary display. `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="/docs/native-ui">Native UI</a>). Platforms without the concept keep standard chrome. The same fields are accepted on top-level `windows` entries. `dock_visible` is top-level app policy, not a window field: setting it false removes the Dock/app-switcher presence but does not hide a window; pair it with `initially_hidden = true` when the app should launch behind its status item.
Overlay presentation is also fixed at create time: `transparent` makes the top-level window alpha-capable, `always_on_top` selects its floating/topmost level, `click_through` passes pointer input to windows underneath, and `activate_on_show = false` reveals it without activating the app or taking focus. Canvas windows already use present-before-show, so these flags are applied while the window is hidden and its first alpha-correct frame becomes the first visible frame. Pair `transparent = true` with a non-opaque `gpu_alpha_mode`; `UiApp.WindowDescriptor` makes that canvas-alpha choice and uses an alpha-zero clear automatically. See the <a href="/docs/windows#overlay-windows">overlay window recipe</a>. These fields are accepted on top-level `windows`, `shell.windows`, runtime `WindowCreateOptions`, and `UiApp.WindowDescriptor`.
Overlay presentation is also fixed at create time: `transparent` makes the top-level window alpha-capable, `always_on_top` selects its floating/topmost level, `click_through` passes pointer input to windows underneath, `activate_on_show = false` reveals it without activating the app or taking focus, and `allows_fullscreen = false` removes macOS fullscreen participation while keeping the window resizable. Canvas windows already use present-before-show, so these flags are applied while the window is hidden and its first alpha-correct frame becomes the first visible frame. `initially_hidden = true` is stronger: it suppresses that reveal until `Cmd.showWindow` or explicit focus. Pair `transparent = true` with a non-opaque `gpu_alpha_mode`; `UiApp.WindowDescriptor` makes that canvas-alpha choice and uses an alpha-zero clear automatically. See the <a href="/docs/windows#overlay-windows">overlay window recipe</a>. These fields are accepted on top-level `windows` and `shell.windows`. Runtime `WindowCreateOptions` exposes the same controls but spells the hidden mode `.show = .hidden`; `UiApp.WindowDescriptor` exposes the overlay controls but not the initially-hidden mode.
Windows also take `close_policy` (`quit`, the default — the close affordance really closes, behavior unchanged for every existing app — or `hide`, the menu-bar-app shape: close hides the window and the app keeps running behind its status item). Like `titlebar`, close handling is host window state fixed at create, and the first shell window's declaration threads through the startup create. `hide` is supported on macOS and Windows — on Windows it additionally requires the `"tray"` capability (the tray icon is the only re-show affordance there; a declaration without it is refused at build time with a teaching). Linux has no status item to bring a hidden window back, so the declaration is refused at build time with a teaching. See <a href="/docs/windows#close-policy">Windows</a> and the <a href="/docs/tray#the-menu-bar-app-lifecycle">tray lifecycle recipe</a>. The same `close_policy` field is accepted on top-level `windows` entries.
@@ -297,11 +311,11 @@ The optional `commands` list declares shared command metadata. The runtime still
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.
Generated zero-config TypeScript and Zig-core runners load manifest commands into `RuntimeOptions.commands`; ejected runners use the same fallback. 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:
The optional `shortcuts` list defines app-level keyboard shortcuts. Generated zero-config TypeScript and Zig-core runners load these automatically, as do ejected runners:
```zig
.shortcuts = .{
@@ -320,7 +334,7 @@ Chromium builds are currently macOS-only; use the Linux system WebView backend w
## `menus`
The optional `menus` list defines native app menus. Generated runners load these automatically:
The optional `menus` list defines native app menus. Generated zero-config TypeScript and Zig-core runners load these automatically, as do ejected runners:
```zig
.menus = .{
+9 -3
View File
@@ -36,7 +36,7 @@ When the runtime publishes a snapshot, it writes these files to the automation d
<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>
<td>Runtime state: source kind, window metadata, native/WebView metadata including role, accessibility label, text, and focus state, the configured command and app-menu catalogs, <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>
@@ -146,8 +146,8 @@ The runtime watches the command queue and processes these actions:
<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>
<td><code>tray-action &lt;item-id&gt;</code> or <code>tray-action &lt;status-item-id&gt; &lt;item-id&gt;</code></td>
<td>Select a status-item dropdown row. The one-id form targets primary status item <code>#1</code>; multiple-item snapshots print <code>tray #id</code> headers</td>
</tr>
<tr>
<td><code>focus &lt;view-label&gt;</code></td>
@@ -261,6 +261,12 @@ Edits refuse rather than guess: a widget authored in Zig, a file that changed on
`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.
## Session recording and blob growth
`NATIVE_SDK_SESSION_RECORD=/path/session.journal` records platform events and effect results; `NATIVE_SDK_SESSION_REPLAY` replays that journal without touching the original network, process, database, or file. Large effect payloads—including every streamed file-read chunk—live content-addressed under the sibling `blobs/` directory, and the journal stores each hash and length. Keep the journal and `blobs/` together when copying a recording.
The blob store deduplicates identical chunks but is intentionally unbounded today: there is no automatic size quota or garbage collector. Long recordings and repeated large imports can therefore grow the directory substantially. Treat a recording as one disposable artifact, monitor its directory size in long automation runs, and delete the journal plus its sibling `blobs/` directory when it is no longer needed. A future GC must reason over every retained journal before removing an unreferenced content address; Tier 5 does not guess at that retention policy.
## Custom directory
Pass a custom path to `automation.Server.init()`:
@@ -49,7 +49,7 @@ Native controls can also bind a `command` when created with `runtime.createView(
</tbody>
</table>
Platform support queries are available through `window.zero.platform.supports(...)` when `js_window_api` is `true`. The command accepts every feature name from `PlatformFeature`: `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`, `gpu_surfaces`, `gpu_surface_scroll_drivers`, `context_menus`, `view_surface_adoption`, `audio_playback`, `audio_streaming`, `audio_spectrum`, and `window_hide_on_close`. JavaScript callers can also use the camelCase aliases — `mainWebView`, `childWebViews`, `nativeViews`, `nativeControlCommands`, `clipboardText`, `clipboardRichData`, `openUrl`, `revealPath`, `recentDocuments`, `fileDrops`, `appActivationEvents`, `gpuSurfaces`, `gpuSurfaceScrollDrivers`, `contextMenus`, `viewSurfaceAdoption`, `audioPlayback`, `audioStreaming`, `audioSpectrum`, and `windowHideOnClose`. 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.
Platform support queries are available through `window.zero.platform.supports(...)` when `js_window_api` is `true`. The command accepts every feature name from `PlatformFeature`: `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`, `gpu_surfaces`, `gpu_surface_scroll_drivers`, `context_menus`, `view_surface_adoption`, `audio_playback`, `audio_streaming`, `audio_spectrum`, `microphone_capture`, `system_audio_capture`, and `window_hide_on_close`. JavaScript callers can also use the camelCase aliases — `mainWebView`, `childWebViews`, `nativeViews`, `nativeControlCommands`, `clipboardText`, `clipboardRichData`, `openUrl`, `revealPath`, `recentDocuments`, `fileDrops`, `appActivationEvents`, `gpuSurfaces`, `gpuSurfaceScrollDrivers`, `contextMenus`, `viewSurfaceAdoption`, `audioPlayback`, `audioStreaming`, `audioSpectrum`, `microphoneCapture`, `systemAudioCapture`, and `windowHideOnClose`. 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
@@ -246,7 +246,7 @@ Dialog commands are **always default-deny** and require an explicit `builtin_bri
<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>
<td>Show or replace a native notification; optional paired <code>actionLabel</code> / <code>actionCommand</code> fields dispatch an app command</td>
</tr>
<tr>
<td><code>native-sdk.os.revealPath</code></td>
@@ -266,7 +266,7 @@ Dialog commands are **always default-deny** and require an explicit `builtin_bri
</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.
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. An actionable notification additionally requires an allowed `native-sdk.command.invoke` policy and the `command` permission for the same origin, because activating it enters the normal app-command path. 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
@@ -466,9 +466,12 @@ const result = await window.zero.invoke("native-sdk.dialog.showMessage", {
await window.zero.os.openUrl("https://example.com/docs/start");
await window.zero.os.showNotification({
id: "build-status",
title: "Build finished",
subtitle: "native-sdk",
body: "All checks passed.",
actionLabel: "Open results",
actionCommand: "build.open-results",
});
await window.zero.os.revealPath("/Users/me/Downloads/report.pdf");
await window.zero.os.addRecentDocument("/Users/me/Downloads/report.pdf");
+131 -11
View File
@@ -1,6 +1,6 @@
# 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](/docs/frontend) — guarded bridge commands. Native code reaches them directly; web content does not receive capability access by default. In a [`UiApp`](/docs/app-model), clipboard access rides the effects channel (`fx.writeClipboard` / `fx.readClipboard`) so `update` never needs a runtime handle.
Native SDK capabilities are native OS services and app events exposed through `PlatformServices`, runtime methods, lifecycle events, and — for apps that [embed web content](/docs/frontend) — guarded bridge commands. Native code reaches them directly; web content does not receive capability access by default. In a [model core](/docs/app-model), OS work rides the effects channel (`Cmd.*` in TypeScript and `fx.*` in Zig) 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`](/docs/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.
@@ -10,7 +10,7 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
<thead>
<tr>
<th>Capability</th>
<th>Zig API</th>
<th>Native API</th>
<th>JavaScript command</th>
<th>Bridge permission</th>
<th>Current native support</th>
@@ -19,17 +19,38 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
<tbody>
<tr>
<td>Open URL in system browser</td>
<td><code>runtime.openExternalUrl(url)</code></td>
<td><code>Cmd.openExternalUrl(url)</code> / <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>
<td>macOS, Linux, and Windows model cores and system WebView; macOS Chromium</td>
</tr>
<tr>
<td>System notification</td>
<td><code>runtime.showNotification(options)</code></td>
<td><code>Cmd.showNotification(spec)</code> / <code>fx.showNotification(options)</code> / <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>
<td>macOS, Linux, and Windows model cores and system WebView; macOS Chromium</td>
</tr>
<tr>
<td>Model persistence</td>
<td><code>Cmd.persist()</code> / <code>fx.persist()</code></td>
<td>None. Model-core effect only.</td>
<td>None. Gated by the <code>persist</code> build capability.</td>
<td>Generated TypeScript app runners on every app-data platform; Zig-core hosts receive the same named <code>core.persist</code> effect seam</td>
</tr>
<tr>
<td>Record store</td>
<td><code>Cmd.store.set/get/delete/scan/setMany</code> / <code>fx.storeSet/storeGet/storeDelete/storeScan/storeSetMany</code></td>
<td>None. Model-core effect only.</td>
<td>None. Gated by the <code>store</code> build capability.</td>
<td>SQLite-backed engine store in the per-app data directory; replay remains offline and the core devhost uses a process-local map</td>
</tr>
<tr>
<td>Relational SQLite</td>
<td><code>Cmd.q&lt;Name&gt;</code> / <code>Cmd.qTx</code> / <code>Sub.q&lt;Name&gt;</code> / raw <code>Cmd.db.query/exec</code> / <code>fx.dbQuery/dbExec/dbSubscribe</code></td>
<td>None. Model-core effect only.</td>
<td>None. Gated by the <code>sqlite</code> build capability.</td>
<td>Checked migrations and named SQL over engine-owned <code>app.db</code>; real in-memory SQLite in check, devhost, and tests; journal-only replay</td>
</tr>
<tr>
<td>Native dialogs</td>
@@ -40,10 +61,10 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
</tr>
<tr>
<td>Reveal path in file manager</td>
<td><code>runtime.revealPath(path)</code></td>
<td><code>Cmd.revealPath(path)</code> / <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>
<td>macOS, Linux, and Windows model cores and system WebView; macOS Chromium</td>
</tr>
<tr>
<td>Recent documents</td>
@@ -82,10 +103,17 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
</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>Cmd.credentials.set/get/delete</code> / <code>fx.credentialsSet/Get/Delete</code> / runtime equivalents</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>
<td><code>credentials</code>. Model cores require both the build capability and permission; WebView commands require the bridge permission.</td>
<td>macOS model cores, system WebView, and Chromium through Keychain; Linux model cores and system WebView through Secret Service/libsecret when available; Windows model cores and system WebView through Credential Manager; iOS toolkit apps through generic-password Keychain entries; Android toolkit apps through an AndroidKeyStore AES-GCM key and authenticated ciphertext in app-private preferences. Core dev and test hosts use hermetic memory.</td>
</tr>
<tr>
<td>Local date/time formatting</td>
<td><code>Cmd.formatLocalTime(timestampMs, style, route)</code> / <code>runtime.formatLocalTime(...)</code></td>
<td>None. Effects/runtime API.</td>
<td>None. No bridge surface.</td>
<td>macOS, Linux, and Windows model cores; locale- and time-zone-aware, with results captured by session recording</td>
</tr>
<tr>
<td>App activation events</td>
@@ -132,6 +160,8 @@ const support = {
audioPlayback: await window.zero.platform.supports("audio_playback"),
audioStreaming: await window.zero.platform.supports("audio_streaming"),
audioSpectrum: await window.zero.platform.supports("audio_spectrum"),
microphoneCapture: await window.zero.platform.supports("microphone_capture"),
systemAudioCapture: await window.zero.platform.supports("system_audio_capture"),
};
```
@@ -143,6 +173,7 @@ OS bridge commands are default-deny. Add explicit `builtin_bridge` entries for e
.builtin_bridge = .{
.enabled = true,
.commands = &.{
.{ .name = "native-sdk.command.invoke", .permissions = .{ "command" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.platform.supports", .permissions = .{ "window" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.openUrl", .permissions = .{ "network" }, .origins = .{ "zero://app" } },
.{ .name = "native-sdk.os.showNotification", .permissions = .{ "notifications" }, .origins = .{ "zero://app" } },
@@ -163,6 +194,8 @@ OS bridge commands are default-deny. Add explicit `builtin_bridge` entries for e
},
```
Because the JavaScript example below supplies a notification action, the app/runtime permission list must also include `command`. Display-only notifications need only the `native-sdk.os.showNotification` policy and `notifications` permission.
Opening external URLs is also gated by the external-link policy:
```zig
@@ -181,9 +214,12 @@ Opening external URLs is also gated by the external-link policy:
```javascript
await window.zero.os.openUrl("https://example.com/docs/start");
await window.zero.os.showNotification({
id: "build-status",
title: "Build finished",
subtitle: "native-sdk",
body: "All checks passed.",
actionLabel: "Open results",
actionCommand: "build.open-results",
});
await window.zero.os.revealPath("/Users/me/Downloads/report.pdf");
await window.zero.os.addRecentDocument("/Users/me/Downloads/report.pdf");
@@ -220,4 +256,88 @@ window.zero.on("app:activate", () => {
});
```
Use the `app.zon` app id as `service` when WebView code and a model core need to address the same entry. Core credential effects apply that namespace automatically; their key corresponds to the bridge `account` field.
## Model cores
Model-driven apps request notifications as effects. Delivery is fire-and-forget because OS focus modes and user settings remain authoritative after the host accepts the request; fake-executor tests and session replay do not display notifications. An optional nonempty `id` replaces the app's earlier notification with the same id. Supply `actionLabel` and `actionCommand` together to route activation through the same application command path as menus, shortcuts, and bridge command invocation. Action-command delivery is process-scoped: a notification from an earlier execution cannot dispatch its command into a later one. Linux notification shells may still D-Bus-activate an exited app when the notification or its action is clicked, but the stale action is rejected rather than entering the new runtime.
### TypeScript
```ts
case "build_finished":
return [model, Cmd.showNotification({
id: asciiBytes("build-status"),
title: asciiBytes("Build finished"),
subtitle: asciiBytes("native-sdk"),
body: asciiBytes("All checks passed."),
actionLabel: asciiBytes("Open results"),
actionCommand: asciiBytes("build.open-results"),
})];
```
### Zig
```zig
.build_finished => fx.showNotification(.{
.id = "build-status",
.title = "Build finished",
.subtitle = "native-sdk",
.body = "All checks passed.",
.action_label = "Open results",
.action_command = "build.open-results",
}),
```
Apps using the lower-level runtime can call the same platform seam directly with `try runtime.showNotification(options)`.
### Credentials
Credentials are app-scoped effects, not Model data. Declare both gates; `native check` reports NS1071 for a missing capability and NS1072 for a missing permission when a TypeScript core uses `Cmd.credentials.*`. NS1073 reserves the underlying `core.credentials.*` request names for these typed factories:
```zig:app.zon
.capabilities = .{ "credentials" },
.permissions = .{ "credentials" },
```
The manifest app id is the OS keychain service namespace and is bounded at 128 bytes, so authored code supplies only a key. Keys are NUL-free UTF-8 through 256 bytes and secrets are bounded at 2,560 bytes, the largest binary value every first-party OS backend can store whole. A get miss routes the error arm with `miss`; the other closed outcomes are `denied`, `locked`, `io_failed`, `over_bound`, and `rejected`. Set and delete route empty bytes on success, and delete is idempotent.
```ts
case "save_token":
return [model, Cmd.credentials.set("api-token", msg.token, {
key: "save-token",
ok: "token_saved",
err: "credential_failed",
})];
case "load_token":
return [model, Cmd.credentials.get("api-token", {
key: "load-token",
ok: "token_loaded",
err: "credential_failed",
})];
case "token_loaded":
// Consume msg.token immediately to construct the next effect. Do not
// copy it into Model, where persistence and state fingerprints can see it.
return [model, Cmd.fetch({
url: asciiBytes("https://api.example.com/me"),
headers: { authorization: msg.token },
}, { ok: "profile_loaded", err: "profile_failed" })];
```
```zig
.save_token => |token| fx.credentialsSet(.{
.key = 41,
.credential_key = "api-token",
.secret = token,
.on_result = Effects.credentialsMsg(.credential_result),
}),
.load_token => fx.credentialsGet(.{
.key = 42,
.credential_key = "api-token",
.on_result = Effects.credentialsMsg(.credential_result),
}),
```
Credential results still cross the effect boundary, but recording never writes secret bytes to the journal or blob store. A successful recorded get keeps only its length, a per-session salt, and a placeholder digest that is deliberately independent of the secret (so the artifact is not a password-guessing oracle). Replay supplies deterministic placeholder bytes of the same length and never opens the live keychain. Consequently, replay is suitable for control-flow verification but cannot perform a new authenticated exchange with the original token. The core devhost also stores credentials only in process memory and prints `<redacted, N bytes>`.
See also: [Builtin Commands](/docs/bridge/builtin-commands) and [Security](/docs/security).
+6 -5
View File
@@ -151,19 +151,20 @@ Package the app for distribution. The manifest is picked up at `app.zon` and the
<dt><code>--team-id</code></dt>
<dd>Apple Developer Team ID.</dd>
<dt><code>--archive</code></dt>
<dd>Create a distributable archive.</dd>
<dd>Create a distributable archive. On macOS this is a styled DMG with the app, an Applications alias, a generated or custom background, and the Finder layout declared by <code>app.zon</code>.</dd>
</dl>
### Platform shortcuts
```sh
native package-windows [--output path] [--binary path]
native package-linux [--output path] [--binary path]
native package-windows [--output path] [--binary path] [--service-binary path]
native package-linux [--output path] [--binary path] [--service-binary path]
native package-ios [--output path] [--binary path]
native package-android [--output path] [--binary path]
```
Per-platform shortcuts for `native package --target <platform>`.
The desktop shortcuts use an explicit `--service-binary` when supplied; otherwise, service-bearing projects discover the normal `zig-out/bin/<app>_services[.exe]` build output just like the canonical command.
### `native bundle-assets`
@@ -237,8 +238,8 @@ Interact with the automation server of a running automation-enabled app. See [Au
<dd>Dispatch a trackpad pinch gesture at a gpu-surface view (<code>scale</code> is the final multiplicative zoom for the gesture; the anchor point defaults to the view center).</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 tray-action &lt;item-id&gt;</code> / <code>automate tray-action &lt;status-item-id&gt; &lt;item-id&gt;</code></dt>
<dd>Select a status-item dropdown row; the one-id shorthand targets primary status item <code>#1</code>.</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>
@@ -4,7 +4,7 @@ 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](/docs/components/radio); for an on/off setting rendered as a sliding thumb, use [switch](/docs/components/switch).
A binary value control. Put its visible label between the tags (the `text` attribute is the equivalent binding-friendly form); `label="..."` alone supplies an accessible name without drawing a 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](/docs/components/radio); for an on/off setting rendered as a sliding thumb, use [switch](/docs/components/switch).
<ComponentPreview name="checkbox" alt="Checkboxes rendered by the engine" caption="checked, unchecked, and disabled checkboxes" />
@@ -12,9 +12,9 @@ A binary value control: the label rides the `text` attribute — checkbox is not
```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" />
<checkbox checked="{accepted}" on-toggle="toggle_terms">Accept terms and conditions</checkbox>
<checkbox checked="{reports}" on-toggle="toggle_reports">Send usage reports</checkbox>
<checkbox checked="true" disabled="true">Managed by your organization</checkbox>
</column>
```
+19 -2
View File
@@ -5,6 +5,8 @@ import { AttrTable } from "@/components/attr-table";
`combobox` is a trigger-only primitive like [select](/docs/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](/docs/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.
Enter submits when `on-submit` is bound; otherwise Enter opens the picker. Space and the open-arrow keys (Down/Up) always open it. Once focus moves into the open menu, Enter selects the focused `menu-item` as usual.
<ComponentPreview name="combobox" alt="A combobox rendered by the engine" caption="a combobox trigger with its search placeholder" />
## Markup
@@ -13,8 +15,8 @@ The model owns the query and the open flag; the `for` source is the model-filter
```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}">
<combobox placeholder="Search frameworks" text="{frameworkQuery}" on-input="framework_edited" on-submit="commit_framework_query" on-press="open_framework_menu" />
<if test="{frameworkMenuOpen}">
<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>
@@ -24,6 +26,19 @@ The model owns the query and the open flag; the `for` source is the model-filter
</stack>
```
In the primary TypeScript core, the submit arm commits the current model-owned query. The open and submit messages remain separate, so Enter can commit without toggling the picker:
```ts
export type Msg =
| { readonly kind: "framework_edited"; readonly edit: TextInputEvent }
| { readonly kind: "open_framework_menu" }
| { readonly kind: "close_framework_menu" }
| { readonly kind: "commit_framework_query" };
case "commit_framework_query":
return { ...model, committedFrameworkQuery: model.frameworkQuery, frameworkMenuOpen: false };
```
## 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`.
@@ -34,6 +49,7 @@ ui.stack(.{ .width = 240 }, .{
.placeholder = "Search frameworks",
.text = model.framework_query,
.on_input = Ui.inputMsg(.framework_edited),
.on_submit = .commit_framework_query,
.on_press = .open_framework_menu,
}, .{}),
if (model.framework_menu_open) ui.el(.dropdown_menu, .{
@@ -56,6 +72,7 @@ ui.stack(.{ .width = 240 }, .{
"disabled",
"on-press",
"on-input",
"on-submit",
"on-dismiss",
]}
/>
+1 -1
View File
@@ -4,7 +4,7 @@ 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](/docs/components/drawer) and [sheet](/docs/components/sheet).
A modal dialog surface centered against the root view, regardless of which container declares it. 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 root-edge surfaces with the same contract, see [drawer](/docs/components/drawer) and [sheet](/docs/components/sheet).
<ComponentPreview name="dialog" alt="A modal dialog rendered by the engine" caption="title chrome, body content, and a trailing action row" />
+4 -4
View File
@@ -3,9 +3,9 @@ import { AttrTable } from "@/components/attr-table";
# Drawer
A side-anchored surface with the same contract as [dialog](/docs/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](/docs/components/sheet).
A bottom-edge surface with the same contract as [dialog](/docs/components/dialog): it spans the root view width and pins to the root bottom, regardless of which container declares it. The title comes from the `text` attribute, visibility is model-owned behind an `if`, and `on-dismiss` dispatches 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 right-edge variant, see [sheet](/docs/components/sheet).
<ComponentPreview name="drawer" alt="A drawer surface rendered by the engine" caption="a side drawer with a title and stacked controls" />
<ComponentPreview name="drawer" alt="A drawer surface rendered by the engine" caption="a bottom drawer with a title and stacked controls" />
## Markup
@@ -13,7 +13,7 @@ A side-anchored surface with the same contract as [dialog](/docs/components/dial
<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">
<drawer text="Filters" height="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>
@@ -31,7 +31,7 @@ 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.el(.drawer, .{ .text = "Filters", .height = 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 }),
@@ -3,7 +3,7 @@ import { AttrTable } from "@/components/attr-table";
# Input Group
The composer shape: one bordered field wrapping a [textarea](/docs/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.
The composer shape: one bordered field wrapping a [textarea](/docs/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` handles submission, `submit-on-enter="true"` opts a chat composer into plain-Enter submission, and `autofocus` lands the keyboard on mount.
<ComponentPreview name="input-group" alt="An input group rendered by the engine" />
@@ -13,7 +13,7 @@ The textarea comes first (document order is focus order), then the optional `inp
```html
<input-group label="Message composer" height="120">
<textarea placeholder="Type a message" text="{draft}" on-input="draft_edited" on-submit="send" />
<textarea placeholder="Type a message" text="{draft}" submit-on-enter="true" 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" />
@@ -33,6 +33,7 @@ ui.inputGroup(.{
}, ui.el(.textarea, .{
.placeholder = "Type a message",
.text = model.draft,
.submit_on_enter = true,
.on_input = Ui.inputMsg(.draft_edited),
.on_submit = .send,
.semantics = .{ .label = "Message" },
@@ -43,6 +43,10 @@ pub fn email(model: *const Model) []const u8 {
</CodeToggle>
## Editing keys
The built-in editor follows the platform keymap in `input`, `text-field`, `search-field`, and `combobox`: Backspace/Delete remove one caret unit, Option+Backspace/Delete on macOS (Ctrl+Backspace/Delete elsewhere) remove one word, and Command+Backspace on macOS (with or without Shift) deletes from the caret to the beginning of the field. A non-empty selection always wins and is deleted by itself. The semantic edit still arrives through `on-input`, so `applyTextInputEvent` and `TextBuffer` keep controlled fields synchronized and one undo restores the whole deletion.
## 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](/docs/components/combobox).
+14 -8
View File
@@ -4,17 +4,19 @@ import { CodeToggle } from "@/components/code-toggle";
# Radio
The single-choice value control, grouped by a `radio-group` row container. Like [checkbox](/docs/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.
The single-choice value control, grouped by a `radio-group`. Give the group an accessible `label` that names the shared choice. Like [checkbox](/docs/components/checkbox), each radio takes its visible label as element content; the `text` attribute is the equivalent binding-friendly form. Descendant radios at any nesting depth form one logical group: one Tab stop, arrows wrap through the choices while Home/End move to the edges, focus and selection move together, and selecting one clears the rest. Bind the model's choice through `checked`; an actual selection transition dispatches `on-change` when bound, then falls back to `on-toggle` and `on-press` for compatibility. Activating the already-checked radio has no new `on-change` edge (a legacy fallback handler still receives the activation).
<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 gap="12" label="Density">
<radio checked="{density == default}" on-change="set_default">Default</radio>
<row>
<radio checked="{density == comfortable}" on-change="set_comfortable">Comfortable</radio>
</row>
<radio checked="{density == compact}" disabled="true">Compact</radio>
</radio-group>
```
@@ -40,9 +42,11 @@ case "set_comfortable":
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_group, .{ .gap = 12, .semantics = .{ .label = "Density" } }, .{
ui.el(.radio, .{ .text = "Default", .checked = model.density == .default, .on_change = .set_default }, .{}),
ui.row(.{}, .{
ui.el(.radio, .{ .text = "Comfortable", .checked = model.density == .comfortable, .on_change = .set_comfortable }, .{}),
}),
ui.el(.radio, .{ .text = "Compact", .checked = model.density == .compact, .disabled = true }, .{}),
})
```
@@ -54,6 +58,8 @@ ui.el(.radio_group, .{ .gap = 12 }, .{
"text",
"checked",
"disabled",
"on-change",
"on-toggle",
"on-press",
]}
/>
+4 -4
View File
@@ -3,9 +3,9 @@ import { AttrTable } from "@/components/attr-table";
# Sheet
A bottom-anchored surface with the same contract as [dialog](/docs/components/dialog) and [drawer](/docs/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.
A right-edge surface with the same contract as [dialog](/docs/components/dialog) and [drawer](/docs/components/drawer): it spans the root view height and pins to the root right, regardless of which container declares it. Visibility is model-owned behind an `if`, and `on-dismiss` dispatches on Escape or click-outside so `update` clears the open flag. Size the sheet with `width`. 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" />
<ComponentPreview name="sheet" alt="A sheet surface rendered by the engine" caption="a right-edge sheet with a title, body text, and an action row" />
## Markup
@@ -13,7 +13,7 @@ A bottom-anchored surface with the same contract as [dialog](/docs/components/di
<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">
<sheet text="Share" width="320" 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>
@@ -32,7 +32,7 @@ A bottom-anchored surface with the same contract as [dialog](/docs/components/di
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.el(.sheet, .{ .text = "Share", .width = 320, .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."),
@@ -3,7 +3,9 @@ import { AttrTable } from "@/components/attr-table";
# Textarea
Multi-line text entry. Like [input](/docs/components/input), `text` and `placeholder` bind from the model and `on-input` names a Msg variant that receives every edit as a text-input event — see [input](/docs/components/input) for the core-side contract in both languages. Enter (and Shift+Enter) inserts a newline instead of submitting; when a textarea carries `on-submit`, the submit rides the primary chord — Cmd+Enter on macOS, Ctrl+Enter elsewhere. Give it a definite `width` and `height` (or a `grow`) to size the editing box.
Multi-line text entry. Like [input](/docs/components/input), `text` and `placeholder` bind from the model and `on-input` names a Msg variant that receives every edit as a text-input event — see [input](/docs/components/input) for the core-side contract in both languages. By default, Enter (and Shift+Enter) inserts a newline; when a textarea carries `on-submit`, submission rides Cmd+Enter on macOS or Ctrl+Enter elsewhere. Chat composers can set `submit-on-enter="true"`: plain Enter then submits, Shift+Enter still inserts a newline, and the primary chord still submits. Give it a definite `width` and `height` (or a `grow`) to size the editing box.
Textarea editing uses the same platform shortcuts as [input](/docs/components/input#editing-keys). On macOS, Command+Backspace (with or without Shift) deletes from the caret to the beginning of the current hard-newline-delimited line (or deletes the active selection); soft-wrapped visual lines use their logical line start in this first version.
<ComponentPreview name="textarea" alt="A textarea rendered by the engine" />
@@ -35,6 +37,7 @@ ui.el(.textarea, .{
"placeholder",
"disabled",
"autofocus",
"submit-on-enter",
"on-input",
"on-submit",
]}
+13 -3
View File
@@ -93,7 +93,7 @@ Prefer **omitting** `cachePath` for URL sources: when the app wiring configures
Exactly one event arm dispatches per load — a five-field record matched by field name: `id`, `state`, `width`, `height`, `status`. `id` echoes the requested ImageId, so two loads in flight at once share one arm and still tell their results apart (that is what the example's `cover: msg.id` reads); `status` is the HTTP status for url loads that performed an exchange, and **0 when none occurred** — local paths and cache hits — so a `loaded` with status 0 is honest signal that the pixels came without a network round trip, never a fabricated 200; `loaded` means the pixels are registered and drawable; everything else names what actually happened:
- **Source classes** — `not_found` (missing local file, no url), `io_failed` (a local read failure), `connect_failed` / `tls_failed` / `protocol_failed` / `timed_out` (the fetch taxonomy, on the fetch machinery's own timeout), `http_status` (a non-2xx answer, with the status carried through — an error page is not an image, so the body is discarded).
- **Decode and registry classes** — the same errors the direct registration API raises: `decode_failed`, `unsupported` (a host without a codec), `too_large`, `registry_full`, and `alloc_failed` (the host refused the memory the registration needed — resource exhaustion, not corrupt bytes: the same source may load once memory frees, so it is never reported as `decode_failed`).
- **Decode and registry classes** — `decode_failed`, `unsupported` (a host without a codec), `registry_full`, and `alloc_failed` (the host refused the memory the registration needed — resource exhaustion, not corrupt bytes: the same source may load once memory frees, so it is never reported as `decode_failed`). `too_large` normally means the encoded source exceeded 8 MiB; platform codecs fit decoded pixels to the app's declared target.
- **Discipline classes** — `rejected` (an invalid id, no source at all, or a duplicate live id: one load per id at a time, the spawn rule — a load in flight is never replaced implicitly) and `cancelled` (`Cmd.imageCancel(id)` ended the load).
`Cmd.imageCancel(id)` is the load's cancel — image loads are keyed by their numeric id, so the string-keyed `Cmd.cancel` never touches them. Cancel is **loud**, the spawn discipline: the one terminal still arrives, as the load's own event arm with state `cancelled`, and the id is free for a fresh load once it lands (a slow CDN fetch no longer pins its id against a retry). Aimed at an id with no live load it no-ops — whatever it targeted already delivered its terminal.
@@ -106,7 +106,17 @@ Unlike a load, unregister is **synchronous registry surgery, not an effect**: no
## Limits, honestly
Decode limits are the registered-image limits, fixed and loud: **16 slots** of **1 MiB decoded pixels** each (512×512 RGBA8 — avatar and cover-art scale, not photo scale), with `Cmd.imageUnregister` releasing a slot when the app is done with an image. The encoded source is bounded at **1.25 MiB** from every source alike, and over-bound sources fail whole with `too_large` — never a silently cropped decode. Loaded pixels live in the existing registered-image storage; there is no separate pool to size. The framework bundles no codecs: bytes decode through CGImageSource on macOS, gdk-pixbuf on GTK, WIC on Windows, and the mobile hosts' embed image service — a host without one answers `unsupported`, never silence.
The default is **16 slots** with a **1 MiB decoded-pixel target per slot**. That target is not a refusal: platform codecs decode photo-scale sources down, preserving aspect, until `width × height × 4` fits. A wide 1024×256 image already fits and stays that size; a 640×480 image registers at a smaller geometry, and the result's `width`/`height` report exactly what views draw. The encoded source has its own flat **8 MiB** bound; an over-bound source fails whole with `too_large`, never as truncated bytes.
Image-centric apps can raise the target in `app.zon`, up to the hard 8 MiB ceiling:
```zig
.images = .{ .max_image_pixel_bytes = 8_388_608 },
```
The value is frozen at startup. Pixel blocks and decode scratch allocate lazily, so declaring a raise costs nothing until an image is used; each used registry slot is a whole-budget block, however, so filling all 16 slots at 8 MiB is a declared **128 MiB** high-water. The source bound stays 8 MiB regardless of this setting.
This gives three clear tiers: decode-to-fit is the default for feeds, galleries, avatars, and covers; the manifest raise serves wallpaper and image-forward apps that want more display-scale detail; pixel-exact editing, source-resolution zoom, and gigapixel tiling belong in an app-owned `gpu_surface` or [media-surface producer](/docs/media-producers), not the registry. The framework still bundles no codecs: bytes decode through CGImageSource on Apple platforms, gdk-pixbuf on GTK, WIC on Windows, BitmapFactory on Android, and the embed image service — a host without one answers `unsupported`.
For textures **produced** by your own renderer at video rates — a decoder, a camera, mpv — this is the wrong tool: that is the [media surface](/docs/media-producers)'s dynamic texture channel. `imageLoad` is for images that exist as encoded bytes somewhere and should become long-lived registered pixels.
@@ -114,7 +124,7 @@ For textures **produced** by your own renderer at video rates — a decoder, a c
The loaded bytes **are** the effect result, and the session journal treats them that way. When a recorded session performs an image load, the encoded source bytes are written — at effect-result time — into a content-addressed blob store beside the journal (`blobs/<sha256[..16]>` in the session directory), and the journal record carries the hash and length. Loading the same bytes twice stores one blob: content addressing is deduplication — and the deduplicating probe verifies the existing blob's bytes before trusting its name, so a damaged blob is repaired in place on the next same-bytes recording rather than sealing a journal replay would refuse.
Replay reads the blob, re-runs the same decode and registration with the recorded bytes, and delivers the recorded result — **byte-identical and fully offline**: the original file, the network, and the cache are never consulted, and the fingerprint checkpoints verify the replayed session against the recording frame by frame. A journal whose `blobs/` directory is missing or damaged refuses loudly (the bytes are verified against their address) rather than replaying a different session. The blob record kind is journal format **v7** — older journals are refused at the preamble with the standard re-record teaching, the format's usual honest break.
Replay reads the blob, re-runs decode and registration with the recorded bytes, and delivers the recorded result — **byte-identical and fully offline**: the original file, the network, and the cache are never consulted, and the fingerprint checkpoints verify the replayed session against the recording frame by frame. If the current manifest lowers the image budget after recording, the recorded result dimensions still replay verbatim while best-effort presentation re-decodes to the current budget; any resulting screenshot difference is a verification mismatch, not false journal damage. A journal whose `blobs/` directory is missing or damaged refuses loudly (the bytes are verified against their address) rather than replaying a different session. The blob record kind is journal format **v7** — older journals are refused at the preamble with the standard re-record teaching, the format's usual honest break.
```sh
NATIVE_SDK_SESSION_RECORD=session/app.journal native run # records blobs/ beside the journal
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("files");
export default function FilesLayout({ children }: { children: React.ReactNode }) {
return children;
}
+70
View File
@@ -0,0 +1,70 @@
# Files and streaming
Raw file effects are the escape hatch for files the user owns: imports, exports, recordings, images, CSV, and other blobs. App state belongs in [Model Persistence](/docs/persistence), the [Record Store](/docs/record-store), or [Relational SQLite](/docs/sqlite); secrets belong in credentials.
Every operation is still an effect. `update` returns a `Cmd`, the host performs I/O after the model commits, and results return as later `Msg` values. Session recording journals those results; streamed chunks live in the content-addressed `blobs/` directory beside the journal so replay never opens the original file.
The blob directory is deduplicated but currently has no quota or automatic GC; see [Automation: Session recording and blob growth](/docs/automation#session-recording-and-blob-growth).
## Small whole-file operations
`Cmd.readFile` and `Cmd.writeFile` remain convenient for payloads up to 1 MiB. An over-bound write is rejected. An over-bound read returns `truncated` rather than passing cut bytes as a successful file. `Cmd.appendFile` appends one payload up to 1 MiB, `Cmd.statFile` reports `{ exists, size, mtimeMs }` without reading the content, and `Cmd.deleteFile` removes one file.
```ts
return [model, Cmd.statFile(path, {
ok: "file_stat",
err: "file_failed",
})];
return [model, Cmd.deleteFile(path, {
ok: "file_deleted",
err: "file_failed",
})];
```
Deletion is file-only and never recursive: it reports `not_found` when the path is absent and `io_failed` for a directory or another OS refusal. If the final path component is a symlink, deletion removes the link rather than its target. File outcomes are closed and machine-readable: `ok`, `not_found`, `io_failed`, `truncated`, `rejected`, `cancelled`, `sink_missing`, `out_of_order`, and `disk_full`.
## Streaming reads
`Cmd.readFileStream` delivers zero or more 256-KiB chunks, followed by exactly one `done` message carrying the total byte count, or one `err` message.
Read-stream keys follow ordinary file semantics: reissuing a live key silently replaces the old read, and `Cmd.cancel(key)` silently drops it. Stale chunks from the retired generation cannot reach the replacement route.
```ts
return [model, Cmd.readFileStream(path, {
key: "import",
chunk: "import_chunk",
done: "import_done",
err: "import_failed",
})];
```
Streaming has its own four-slot budget and no total-size ceiling. Each individual chunk remains bounded, so one large import cannot consume the sixteen general spawn/fetch/whole-file slots.
## Atomic streaming writes
Open a sink, send one acknowledged chunk at a time, then close it. Close syncs the temporary file and atomically replaces the destination; before a successful close, the old destination stays visible and teardown removes the temporary file.
```ts
Cmd.writeFileStream("export", path, { ok: "sink_open", err: "export_failed" });
Cmd.writeFileChunk("export", bytes, { ok: "chunk_written", err: "export_failed" });
Cmd.writeFileClose("export", { ok: "export_done", err: "export_failed" });
```
A duplicate live sink rejects. A chunk or close issued before the previous acknowledgment returns `out_of_order`; a chunk or close with no open sink returns `sink_missing`. Chunks may be at most 1 MiB.
Write sinks use the loud stream discipline: `Cmd.cancel(key)` ends the sink through `err: cancelled` and removes its temporary file. A half-written export is never silently replaced or installed.
## Filesystem permission
Raw paths inside this app's resolved `data`, `config`, `cache`, `state`, `logs`, and `temp` directories need no permission. Any path outside those roots requires:
```zig
.permissions = .{ "filesystem" },
```
The runtime is authoritative. Before checking, it resolves the target when it exists, or resolves the deepest existing parent and normalizes the missing suffix. Existing symlinks are followed for authorization, so a symlink inside an app directory that points outside is external and requires the permission. Deletion still unlinks the final symlink itself after that check. `..` cannot escape an allowed root.
`native check` also reports NS1074 for certainly-external literal paths, but dynamic paths are decided only by the runtime. File pickers therefore require the `filesystem` permission because the selected user file is normally outside app-owned directories.
Directory enumeration, file watching, chmod/permissions, and per-path grant prompts are not part of this API.
+1 -1
View File
@@ -14,7 +14,7 @@ The Native SDK can configure native app menus from `app.zon` or typed Zig data.
},
```
Generated runners load `app.zon` menus automatically. Pass `menus` to `runWithOptions` when an app needs to override the manifest at runtime:
Generated zero-config runners load `app.zon` menus automatically for both TypeScript cores and Zig cores. Ejected runners use the same fallback. Pass a non-null `menus` slice to `runWithOptions` when lower-level Zig wiring needs to override the manifest at runtime (an explicit empty slice disables manifest menus):
```zig
const view_items = [_]native_sdk.MenuItem{
+63 -15
View File
@@ -48,7 +48,7 @@ The runtime owns the rest: install, presentation, resize, typed event dispatch,
| `dropdown-menu` | menu surface | vertical; children are `menu-item`s. `anchor="below\|above"` floats it against its parent's frame: a late z-pass above the whole tree, window-clipped (never cropped by a scroll pane), auto-flipping at the window edges, zero flow space — pair with `on-dismiss` so Escape/click-outside close model-side |
| `accordion` | accordion | header via the `text` attribute; children show while `selected`, dispatch `on-toggle` |
| `alert`, `bubble` | surfaces | `alert` title via the `text` attribute; children stack inside. `bubble` hugs its message up to 80% of the thread (`ghost` exempt; explicit `width` wins) and takes one `<reactions>` child — the reaction pill straddling its bottom edge, one text run, dock via `text-alignment` (default `end`); `text=` on bubble itself is a teaching error (that channel belongs to the pill) |
| `dialog`, `drawer`, `sheet` | modal surfaces | rendered in place — title via `text`, wrap in `<if>` to show conditionally |
| `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`, wrap in `<if>` to show conditionally |
| `resizable` | resizable panel | engine-managed drag handle; `width` sets the initial width |
| `split` | two-pane 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, `on-resize` names an f32 Msg variant dispatched with each applied fraction (echo it back through `value`), `min-width` on the panes bounds the drag, `gap` sets the divider band thickness; the focused divider takes Left/Right (Shift for bigger steps) and Home/End. `resize-duration` (milliseconds, split only) animates model-driven value moves — the runtime eases the rendered fraction to the new value one presented frame at a time instead of snapping, and reduced-motion appearances snap automatically; `resize-easing` (`linear`/`standard`/`emphasized`/`spring`) shapes the ramp and needs a nonzero duration beside it |
| `tree` | disclosure tree | vertical container whose descendant rows with `role="treeitem"` form one roving keyboard focus set: Up/Down walk visible rows (selection follows focus via `on-change` when bound, otherwise `on-press`), Left collapses or moves to the parent row, Right expands or moves to the first child row, Home/End jump to the edges, Enter/Space activate; nested rows derive hierarchy structurally, while flat `<for>` rows declare their one-based `tree-level`; expandable rows bind `expanded` and `on-toggle`, and the model owns both states |
@@ -92,7 +92,7 @@ A few widget kinds are deliberately **not** markup elements because their shape
Apps with their own iconography can parse any stroke-dialect/Feather/Tabler-dialect SVG at comptime (`canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg"))`) and register it at boot with `canvas.icons.registerAppIcons(&table)`: the draw paths (icon leaves via `ui.appIcon`, `ElementOptions.icon` on buttons, toggle buttons, icon buttons, and list/menu items) resolve registered names exactly like built-ins. Markup reaches them through the `app:` NAMESPACE (`<icon name="app:waveform"/>`, `icon="app:waveform"`): bare names keep the closed built-in vocabulary (the compiled engine proves them at comptime, where a runtime registration cannot exist), while `app:` names are structurally accepted by both engines and verified by `native check` against the model contract - declare the table as `pub const app_icons` on the app root so the contract emit reflects the same names `main` registers. Bound icon names (`icon="{binding}"`) make the choice model data - a per-row status icon, a play/pause toggle - and any name that fails to resolve at draw time renders the missing-icon fallback (a slashed circle) with a Debug warning naming the value, never a silent gap.
Layout attributes: `gap`, `padding`, `grow`, `width`, `height`, `wrap`, `text-alignment`, `columns`, `main`, `cross`, `virtualized`, and the anchored-floating family on `dropdown-menu` and `tooltip`: `anchor` (`below`/`above` — floats the surface against its parent, flipping when the preferred side does not fit), `anchor-alignment` (`start`/`end`/`stretch`), `anchor-offset` (points, default 4), plus `tooltip-delay` on `tooltip` alone (the hover-intent show delay in milliseconds, default the 600ms token; `"0"` shows the instant the trigger is hovered; keyboard-focus reveals are always immediate — a teaching error without `anchor` beside it). An anchored tooltip's visibility is runtime-owned hover intent on its trigger, unlike the model-owned dropdown. `gap` belongs to flow containers: the stacking kinds (`stack`, `panel`, `card`, and the surface/modal elements) layer their children, so `gap` there is rejected with a teaching error — wrap the children in a `column` (or `row`) inside for flow (on `split` it sets the divider band thickness). `width` and `height` are definite sizes: the element is exactly that size, so intrinsic content neither shrinks nor silently overflows it (`resizable` treats `width` as its initial width), and debug builds log a `zero_canvas_layout` diagnostic whenever children's minimum sizes overflow their container. `min-width` is a floor without the definite max: the element may grow past it but never shrink below — on split panes it is what bounds the divider drag. `wrap` applies to `text` only: `wrap="true"` word-wraps the content at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are the honest single-line mode — the content measures and paints as one line, and content that does not fit follows `overflow`. `overflow` (`text` only) names the single-line policy for content that does not fit: `ellipsis` (the default) elides the tail behind a trailing … measured with the same metrics paint uses, the right choice for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column, where "1…" would be worse than a partial glyph. There is deliberately no overflow-visible — painting past the frame is the bug class the layout audit exists to catch. `text-alignment` (`start`|`center`|`end`) aligns text content in text leaves, status bars, and surface titles. `columns` fixes a `grid`'s column count (grid-only — anywhere else it is a teaching error; omit it for the derived near-square grid). `tree-level` gives flat sibling rows with `role="treeitem"` a one-based logical depth so Left/Right can resolve parents and children; omit it for structurally nested rows. Appearance: `variant`, `size` (the control scale `default`|`sm`|`lg`|`icon` on every sized element; on `text` also the typography rungs `heading`|`display` — named typography token steps for section headings and hero stats, themable like every token, and text-only: on a control they are a teaching error naming text as their home; numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `expanded` (tree rows: model-owned disclosure state; omit on leaves). Focus: `autofocus` (focusable controls only) 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; it is the model-driven way to focus an editor on create or give a keyboard-first app its first focus. Semantics: `role` (`treeitem` also makes a row part of its tree's roving focus set), `label` (an explicit accessible name — it replaces the element's text; see [Accessibility](#accessibility)). Identity: `key` (sibling-scoped) and `global-key` (survives moving between containers — board cards, tab pages). Window chrome: `window-drag="true"` marks the element as a window-drag surface for hidden-titlebar windows (see [Hidden titlebar](#hidden-titlebar-drag-regions-and-chrome-insets)).
Layout attributes: `gap`, `padding`, `grow`, `width`, `height`, `min-width`, `max-width`, `wrap`, `text-alignment`, `columns`, `main`, `cross`, `virtualized`, and the anchored-floating family on `dropdown-menu` and `tooltip`: `anchor` (`below`/`above` — floats the surface against its parent, flipping when the preferred side does not fit), `anchor-alignment` (`start`/`end`/`stretch`), `anchor-offset` (points, default 4), plus `tooltip-delay` on `tooltip` alone (the hover-intent show delay in milliseconds, default the 600ms token; `"0"` shows the instant the trigger is hovered; keyboard-focus reveals are always immediate — a teaching error without `anchor` beside it). An anchored tooltip's visibility is runtime-owned hover intent on its trigger, unlike the model-owned dropdown. `gap` belongs to flow containers: the stacking kinds (`stack`, `panel`, `card`, and the surface/modal elements) layer their children, so `gap` there is rejected with a teaching error — wrap the children in a `column` (or `row`) inside for flow (on `split` it sets the divider band thickness). `width` and `height` are definite sizes: the element is exactly that size, so intrinsic content neither shrinks nor silently overflows it (`resizable` treats `width` as its initial width), and debug builds log a `zero_canvas_layout` diagnostic whenever children's minimum sizes overflow their container. `min-width` is a floor without the definite max: the element may grow past it but never shrink below — on split panes it is what bounds the divider drag. `max-width` is the inverse ceiling without a definite minimum, so the element still shrinks with a narrow parent; a growing child inside a centered row is the responsive content-column pattern. `wrap` applies to `text` only: `wrap="true"` word-wraps the content at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are the honest single-line mode — the content measures and paints as one line, and content that does not fit follows `overflow`. `overflow` (`text` only) names the single-line policy for content that does not fit: `ellipsis` (the default) elides the tail behind a trailing … measured with the same metrics paint uses, the right choice for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column, where "1…" would be worse than a partial glyph. There is deliberately no overflow-visible — painting past the frame is the bug class the layout audit exists to catch. `text-alignment` (`start`|`center`|`end`) aligns text content in text leaves, status bars, and surface titles. `columns` fixes a `grid`'s column count (grid-only — anywhere else it is a teaching error; omit it for the derived near-square grid). `tree-level` gives flat sibling rows with `role="treeitem"` a one-based logical depth so Left/Right can resolve parents and children; omit it for structurally nested rows. Appearance: `variant`, `size` (the control scale `default`|`sm`|`lg`|`icon` on every sized element; on `text` also the typography rungs `heading`|`display` — named typography token steps for section headings and hero stats, themable like every token, and text-only: on a control they are a teaching error naming text as their home; numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `expanded` (tree rows: model-owned disclosure state; omit on leaves). Focus: `autofocus` (focusable controls only) 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; it is the model-driven way to focus an editor on create or give a keyboard-first app its first focus. Semantics: `role` (`treeitem` also makes a row part of its tree's roving focus set), `label` (an explicit accessible name — it replaces the element's text; see [Accessibility](#accessibility)). Identity: `key` (sibling-scoped) and `global-key` (survives moving between containers — board cards, tab pages). Window chrome: `window-drag="true"` marks the element as a window-drag surface for hidden-titlebar windows (see [Hidden titlebar](#hidden-titlebar-drag-regions-and-chrome-insets)).
## Styling with design tokens
@@ -197,13 +197,13 @@ Type discipline is teaching errors over silent coercion: a string minus a number
The function library is closed — seventeen functions, and growing the set is a toolkit change: `fixed(x, digits)` (exact decimals), `thousands(n)` (`1,234,567`), `percent(fraction, digits?)` (`0.42` → `42%`), `date(ts)`/`time(ts)`/`datetime(ts)` (a model unix timestamp in seconds, formatted in UTC — formatting model time is pure; *reading the clock* is an effect, so `now()` is a teaching error pointing at the model/fx loop), `upper`/`lower`/`trim` (ASCII case mapping; other characters pass through), `min`/`max`/`abs`, `round`/`floor`/`ceil` (number → whole number), `plural(count, singular, plural)` (`{plural(n, 'item', 'items')}`), and `pad(x, width)` (zero-pads the integer value of x to `width` digits — `pad(7, 2)` → `07`, a negative sign precedes the zeros without counting toward the width, and numbers wider than `width` print in full; the mm:ss counter function: `{pad(minutes, 2)}:{pad(seconds, 2)}`).
Complexity is bounded and taught one past the bound: at most 256 bytes, 64 terms, and 16 nesting levels per expression — anything larger is a named model function by design.
Complexity is bounded and taught one past the bound: at most 256 bytes, 64 terms, and 16 nesting levels per expression — anything larger is a named core helper by design.
The same line separates inline arithmetic from model functions. Inline expression arithmetic is sanctioned for one-off presentation-level derivation — `{percent(done / total)}` on the single readout that shows it is exactly what expressions are for. The moment a derivation is reused in a second binding, deserves a name, or carries meaning the model owns (a threshold, a rule, a policy), it belongs in a named model function: `{completionRate}` reads at the binding site, tests in Zig, and changes in one place.
The same line separates inline arithmetic from core helpers. Inline expression arithmetic is sanctioned for one-off presentation-level derivation — `{percent(done / total)}` on the single readout that shows it is exactly what expressions are for. The moment a derivation is reused in a second binding, deserves a name, or carries meaning the model owns (a threshold, a rule, a policy), it belongs in a named helper: `{completionRate}` reads at the binding site, tests in the core's language, and changes in one place.
Bindings resolve against your model: struct fields, zero-argument public methods, and — for `for each` — slices, public array declarations, or functions taking `(*const Model)` or `(*const Model, std.mem.Allocator)` (the allocator variant is how filtered lists work). Enums resolve to their tag names. Expressions are allowed in text interpolation, attribute values, `if` tests, and template args at use sites; message tags and payloads, `for each` iterables, and import paths stay path-only.
In the default TypeScript core, model fields bind by their authored names (`nextId` is `{nextId}`), and an exported helper declared in `src/core.ts` with exactly one `Model` parameter becomes a derived binding under its exported name. Scalar-returning helpers drive text/attributes; array-returning helpers drive `for each` and chart series; item record fields continue the path. See [TypeScript Cores](/docs/typescript) for the supported return shapes and module boundary.
Scalar bindings take the allocator form too: `{summary}` binds `pub fn summary(m: *const Model, arena: std.mem.Allocator) []const u8` directly, formatting a derived display string into the build arena — it works in text interpolation, attribute values, message payloads, and as function arguments (`{upper(summary)}`). The one exclusion is comparison operands (`==`, `<`, ...), which reject arena-computed values with a teaching error: compare the source fields, or bind a `bool`-returning method. For `<if test>`, write the predicate out (`test="{count > 0}"`) or bind one (`test="{hasItems}"`) instead of leaning on numeric truthiness.
In a Zig core, bindings resolve struct fields and zero-argument public model methods. A `for each` iterable may also be a slice, public array declaration, `pub fn (*const Model) []const T`, or `pub fn (*const Model, std.mem.Allocator) []const T`; the allocator form derives filtered rows into the one-build arena. Scalar bindings take that form too: `{summary}` binds `pub fn summary(m: *const Model, arena: std.mem.Allocator) []const u8`. Arena-computed values work in text, attributes, message payloads, and function arguments, but not as comparison operands — compare source fields or bind a boolean helper. In either tier, write `<if test>` predicates explicitly (`test="{count > 0}"` or `test="{hasItems}"`) instead of leaning on numeric truthiness. Expressions are allowed in text interpolation, attribute values, `if` tests, and template args at use sites; message tags and payloads, `for each` iterables, and import paths stay path-only.
## Messages
@@ -238,6 +238,8 @@ pub fn draft(model: *const Model) []const u8 {
`on-resize` (on the `split` element; `Ui.valueMsg(.tag)` on `on_resize` in Zig views) names a variant whose payload is the new first-pane fraction (`f32`): after every divider drag, keyboard adjustment, or assistive increment/decrement the runtime delivers the fraction it already applied and clamped — store it in the model and echo it back through the split's `value`, and rebuilds never fight live resizing.
`on-drag` makes any element a draggable spatial object and names the closed record `{ sourceId, phase, x, y, viewWidth, viewHeight }`. Markup supplies numeric `sourceId` from a binding such as `on-drag="card_dragged:{card.id}"`; the runtime supplies `phase` (a number able to represent 0, 1, and 2) plus floating-point view-local `x`, `y`, `viewWidth`, and `viewHeight` (`f32`/`f64` in Zig, `number` in TypeScript). Geometry stays floating-point because pointer capture can carry negative coordinates outside the view. Phase 0 means motion, 1 release, and 2 cancellation. The renderer lifts the actual source appearance under the pointer at full opacity and leaves its in-flow space blank. Apps that need precise insertion can keep committed data unchanged during phase 0 while returning a derived view that moves the same `global-key` into the candidate position. Its hidden in-flow rendering is the one card-sized reserved slot: it begins at the source, moves to each candidate, and never duplicates. Keyed draggable neighbors ease between candidate poses; release carries the floating item from its pointer position into that slot and commits the exact order. A plain Escape during the drag dispatches phase 2, consumes that key, and carries the item back to its source slot; pointer cancellation uses the same path. Reduced-motion appearances snap these reflows. `examples/kanban` demonstrates within-column and cross-column reordering with this pattern.
`on-dismiss` (on the dismissible surfaces: `dialog`, `drawer`, `sheet`, `dropdown-menu`; `ElementOptions.on_dismiss` in Zig views) dispatches when Escape or a click outside dismisses the surface, so the model owns the close — clear the open flag in `update`. The engine hides the surface immediately as an optimistic echo; the next rebuild's source tree is truth. Escape works regardless of focus: it dismisses the nearest surface up the focused widget's chain, and when nothing relevant is focused — a menu opened from a plain-text trigger takes no focus — it falls back to the topmost mounted anchored surface. `on-hold` (any element; `ElementOptions.on_hold`) is press-and-hold: a pointer held ~350 ms dispatches the hold Msg and the release presses nothing, a quick click dispatches `on-press` as usual, and a right/ctrl-click with no context menu on its route dispatches the hold Msg immediately (a declared `<context-menu>` always wins the right-click) — the crumb-switcher shape (`on-press` selects, `on-hold` opens an anchored menu). Both legs are live-drivable through automation: `native automate widget-hold <view> <id>` runs the pointer+timer gesture, `widget-context-press <view> <id>` the secondary click.
`on-hover-enter` and `on-hover-leave` (any element; `ElementOptions.on_hover_enter` / `on_hover_leave`) are the pointer-hover pair — Elm's `onMouseEnter`/`onMouseLeave`: enter dispatches once when the pointer enters the element's hit region, leave once when it exits — discrete containment edges, never per-move, so hover previews, prefetch, and hover cards are ordinary Msgs. Binding either makes the element hover-hittable the way a bound press makes it pressable (so both are legal everywhere, and a pointer over plain text or icons inside counts as inside), but never pressable: clicks keep falling through, no accessibility action is announced, and no hover wash appears — the wash stays the visual channel of acting controls ([state washes](/docs/building-components#theming-your-component)), so a `quiet-hover` content tile that binds hover stays visually quiet while the model hears it. Nested bound elements track containment independently (moving onto a bound row inside a bound card never leaves the card); enters fire outermost-first and leaves innermost-first. Every enter is answered by exactly one eventual leave while the app runs: the leave Msg is captured while the element stands — kept fresh as rebuilds change the binding, retained from the last build that bound one — so it still arrives when the exit is the element unmounting. Exits follow the same resolution the hover wash already uses: moving off and the pointer leaving the window are direct edges; content scrolling or reflowing out from under a stationary pointer re-hit-tests the last pointer position (a scrolled list fires the same leave and enter a real move would); and a dismissal removing the surface under the pointer delivers that surface's leaves immediately, with whatever it reveals entered when the model's close rebuild re-hit-tests — pair dismissible surfaces with `on-dismiss`, as always. Overlays occlude hover exactly as they occlude clicks (the topmost surface under the pointer wins). Hover comes from mouse and trackpad pointers only: containment advances on hover-phase motion — a pointer floating without contact, which touch physically cannot produce — so touch input never synthesizes hover, and anything reachable only by hover must stay reachable another way.
@@ -246,7 +248,7 @@ pub fn draft(model: *const Model) []const u8 {
On a `list-item`, `on-submit` grows a second home beyond text entry: with a submit handler bound, plain Enter on a ring-focused row dispatches it as the row's PRIMARY action (open the record, play the track — the desktop list convention), while Space keeps the row's select activation (`on-press`). Rows without a submit handler resolve Enter exactly as before — both activation keys select. It is the keyboard mirror of `on-double-press`: bind both to the same Msg and pointer and keyboard users share one primary action (the soundboard's Zig track rows bind `on_press` select, `on_double_press` play, `on_submit` play). How Enter reaches the row at all — and why arrows may not — is [keyboard routing](#keyboard-routing-focus-registers-quiet-list-rows-and-the-app-level-fallback).
Presses follow one rule: a click lands on the nearest pressable widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest pressable ancestor, and dragging still selects text. Any element with a bound `on-press`, `on-double-press`, or `on-toggle` is pressable (the handler makes it a hit target), so a pressable row is just `<panel on-press="open:{id}">` — or `<row on-press=...>` — with plain text children: no overlays, no duplicated handlers. Nested pressables resolve to the deepest one (a button inside a pressable row wins); editable text fields, scroll containers, and modal surfaces always claim their own presses. Value/text handlers (`on-change`, `on-submit`, `on-input`) still belong on controls only — the validator, both engines, and the LSP reject them on layout/decoration elements with a teaching error.
Presses follow one rule: a click lands on the nearest press-claiming widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest interactive ancestor, and dragging still selects text. Any element with `on-press`, `on-double-press`, `on-toggle`, `on-hold`, or `on-drag` becomes a hit target and press claimer, so a row can bind `on-press` for clicks or `on-drag` for spatial dragging and child text still resolves to that row without overlays or duplicated handlers. Nested claimers resolve to the deepest one; editable text fields, scroll containers, and modal surfaces always claim their own presses. Value/text handlers (`on-change`, `on-submit`, `on-input`) still belong on controls only — the validator, both engines, and the LSP reject them on layout/decoration elements with a teaching error.
## Keyboard routing: focus registers, quiet list rows, and the app-level fallback
@@ -305,13 +307,28 @@ export function pinchMsg(pinch: PinchEvent): Msg | null {
macOS emits pinch today (trackpad `magnifyWithEvent:`); Windows precision-touchpad and GTK gesture sources are staged follow-ups — on those platforms the channel simply never fires. Everything flows from the journaled input events, so recorded sessions replay the identical zoom, and tests (or agents without a trackpad) drive the real event stream with `native automate widget-pinch <view-label> <scale> [x y]` — `<scale>` is the final multiplicative zoom for the gesture (one change event whose product lands exactly on it), anchor point defaulting to the view center.
## File drops
Native file drops reach a Zig `UiApp` through `Options.on_drop`, after any targeted canvas-widget drop handler. The event carries the source `window_id`, `view_label`, an optional view-local `point`, and every dropped path. TypeScript cores export the same channel as `dropMsg`; import its structural record from `@native-sdk/core/events`. Paths are `Uint8Array` byte text so non-ASCII filesystem names cross without a JS string conversion:
```ts
import { type FileDropEvent } from "@native-sdk/core/events";
export function dropMsg(drop: FileDropEvent): Msg | null {
if (drop.paths.length === 0) return null;
return { kind: "file_opened", path: drop.paths[0] };
}
```
The platform event is journaled before either route, so record/replay delivers the identical source, point, and path bytes. The macOS system host reports labeled, view-local points for canvas and WebView drops; an unlabeled window region keeps an empty `viewLabel` and reports a window-content point. A host that cannot resolve either leaves `viewLabel` empty and `point` null.
## Native scrolling and context menus
On macOS, every non-virtualized `scroll` region is driven by an invisible `NSScrollView`: momentum and the system overlay scrollbar are OS-computed while the engine renders the content. This needs no app code — scroll offsets stay on the widget (`sync`, snapshot offsets, and programmatic scrolls work unchanged), and the engine's drawn scrollbar stands down for natively driven regions. Other platforms keep the engine's wheel physics.
Scroll regions pin at their content edges by default on every path — no rubber-band bounce; kinetic motion stops cleanly at the boundary. Bouncing is a per-region opt-in: `overscroll="rubber_band"` in markup or `ElementOptions.overscroll = .rubber_band` in Zig views (the native macOS scroller gets elastic edges, the engine physics overscroll under resistance and spring back). The `ScrollPhysics.overscroll` design token flips the app-wide default; per-region values override it, and `overscroll="none"` pins a region regardless of the token.
Right/ctrl-click presents a real OS context menu at the pointer: `NSMenu` on macOS, `TrackPopupMenu` on Windows, `GtkPopoverMenu` on Linux. Authors declare ONE menu and the platform decides presentation: hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today) mount the same declared items as an anchored canvas surface at the click point automatically — never two authored menus, never a canvas imitation where the OS menu exists. In markup, the menu is a `<context-menu>` child of the pressable element it answers, holding `menu-item`s (`on-press` required, `disabled` optional) and `separator`s, with `if`/`else`/`for` to swap or repeat items:
Right/ctrl-click presents a real OS context menu at the pointer: `NSMenu` on macOS, `TrackPopupMenu` on Windows, `GtkPopoverMenu` on Linux. Authors declare ONE menu and the platform decides presentation: hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today) mount the same declared items as an anchored canvas surface at the click point automatically — never two authored menus, never a canvas imitation where the OS menu exists. In markup, the menu is a `<context-menu>` child of the interactive element it answers — a hit-target kind or an element made interactive by `on-press`, `on-double-press`, `on-toggle`, `on-hold`, or `on-drag` — holding `menu-item`s (`on-press` required, `disabled` optional) and `separator`s, with `if`/`else`/`for` to swap or repeat items:
```html
<list-item on-press="open:{entry.id}" label="{entry.title}">
@@ -420,7 +437,7 @@ case "fetched":
Response bodies are binary-safe and bounded (256 KiB; longer arrives cut with `truncated = true`), the whole exchange honors a per-fetch timeout (default 30 s), and cancelling a fetch delivers exactly one `cancelled` Msg with nothing after it.
Files ride the same channel: `fx.writeFile` / `fx.readFile` persist app state — session snapshots, transcripts — without smuggling an `Io` handle from `main` into `update`. Bounded (1 KiB paths, 1 MiB files), key-based, one terminal Msg per operation with an explicit outcome (`ok`, `not_found`, `io_failed`, `truncated` — an over-bound read's own outcome, so a cut JSON snapshot cannot parse as whole — `rejected`, `cancelled`); writes create missing parent directories and replace the file whole:
Files ride the same channel without smuggling an `Io` handle from `main` into `update`. Whole-file reads and writes remain bounded at 1 MiB; `appendFile`, `statFile`, and `deleteFile` handle logs and file lifecycle, while the dedicated streaming family carries large imports and atomic exports. External paths require the `filesystem` permission; app-owned directories are exempt after symlink-safe normalization. See [Files & Streaming](/docs/files).
```zig
.save => fx.writeFile(.{
@@ -432,13 +449,44 @@ Files ride the same channel: `fx.writeFile` / `fx.readFile` persist app state
.saved => |result| model.noteSaved(result.outcome),
```
Desktop notifications use the same bounded platform-services facade but are intentionally fire-and-forget: once the host accepts a request, the OS can still suppress it through Focus / Do Not Disturb or the user's notification settings, so a success Msg would over-promise. A nonempty `id` replaces an earlier notification with that id. Paired `actionLabel` / `actionCommand` fields add an action whose activation returns through the ordinary application command path while the process is running. Return `Cmd.showNotification` from a TypeScript core or call `fx.showNotification` from a Zig `update_fx` arm; invalid or over-bound fields, unpaired action fields, invalid command names, and unavailable services fail closed. The fake executor and session replay never display one; the null test platform records and can activate one deterministically.
<CodeToggle>
```ts
case "build_finished":
return [model, Cmd.showNotification({
id: asciiBytes("build-status"),
title: asciiBytes("Build finished"),
subtitle: asciiBytes("native-sdk"),
body: asciiBytes("All checks passed."),
actionLabel: asciiBytes("Open results"),
actionCommand: asciiBytes("build.open-results"),
})];
```
```zig
.build_finished => fx.showNotification(.{
.id = "build-status",
.title = "Build finished",
.subtitle = "native-sdk",
.body = "All checks passed.",
.action_label = "Open results",
.action_command = "build.open-results",
}),
```
</CodeToggle>
Failure and overflow are always visible: a spawn that cannot run delivers an exit Msg with reason `rejected`, a fetch that cannot run delivers a response Msg with outcome `rejected`, and a file effect that cannot run delivers a result Msg with outcome `rejected`; dropped or truncated lines carry counts and flags; `cancel` kills and reaps the process and always ends in exactly one `cancelled` exit Msg, with no further line Msgs after it. Tests use the fake executor (`effects.executor = .fake`) to assert on spawn, fetch, and file requests and feed synthetic lines, stderr (`feedStderr`, collect spawns), exits, responses, and file results back deterministically — set it before the first frame and `init_fx` boot spawns are recorded too. See `examples/effects-probe`.
For timestamps, the facade owns the clocks (Zig 0.16 puts `std.time` behind `std.Io`, which `update` never sees): `native_sdk.nowMs()` / `nowNanoseconds()` read the wall clock and `monotonicMs()` / `monotonicNanoseconds()` the duration clock. Time-dependent logic stores the `native_sdk.Clock` seam in the model (`.system` by default) so tests substitute a deterministic `native_sdk.TestClock` and advance it by hand.
## Secondary windows
Windows are model-declared, exactly like the tray: `Options.windows_fn` returns the window descriptors that should exist right now (presence IS visibility — there is no `visible` flag because the platform window channel is create/focus/close with no hide), and `Options.window_view` builds each declared window's whole canvas tree, keyed by the descriptor's window label. After every dispatched Msg the runtime reconciles: windows the model started declaring are created (a source-less native window wearing one `gpu_surface` view with the descriptor's `canvas_label`, inheriting the main canvas's gpu options), windows it stopped declaring close, and every open window's view rebuilds from the same model — a theme picked in the settings window restyles the main window on the same dispatch.
In a default TypeScript app, export `windows(model): readonly WindowDescriptor[]` and put each possible window's markup at `src/windows/<label>.native`; the generated launcher compiles those files, maps descriptor labels to them, and includes shared components they import from nested paths under `src/windows/`. Construct descriptors with `windowDescriptor`. `restorePolicy: "center_on_primary"` centers a fresh descriptor with no `x`/`y` on macOS; Windows and Linux currently keep their native default placement. The default policy is `"clamp_to_visible_screen"`. `closePolicy: "quit"` routes `onCloseCommand` through `commandMsg`; `"hide"` retains the window and dispatches no close command. `titlebar` includes `"chromeless"` for fully skinned windows. `examples/system-monitor-ts` is the complete default-path reference.
In Zig cores and custom wiring, windows are model-declared exactly like the tray: `Options.windows_fn` returns the window descriptors that should exist right now (presence IS liveness), and `Options.window_view` builds each declared window's whole canvas tree, keyed by the descriptor's window label. After every dispatched Msg the runtime reconciles: windows the model started declaring are created (a source-less native window wearing one `gpu_surface` view with the descriptor's `canvas_label`, inheriting the main canvas's gpu options), windows it stopped declaring close, and every open window's view rebuilds from the same model — a theme picked in the settings window restyles the main window on the same dispatch. There is no `visible` flag: transient visibility is host state changed through `hideWindow`/`showWindow` or a `.hide` close policy; stop declaring a window to really close it and release its retained views.
```zig
fn windows(model: *const Model, scratch: *App.WindowsScratch) []const App.WindowDescriptor {
@@ -467,7 +515,7 @@ fn windowView(ui: *App.Ui, model: *const Model, window_label: []const u8) App.Ui
// options: .windows_fn = windows, .window_view = windowView,
```
Input from any window dispatches Msgs through that window's own tree with its window identity, and a USER close (the titlebar close button) dispatches the descriptor's `on_close` Msg — the dismissal precedent applied to windows: the window is already gone as an optimistic echo, the model clears its open flag in `update` (or keeps declaring the window and the next rebuild brings it back — source wins). A close the model itself initiated (it stopped declaring the window) never echoes a Msg. At most `UiApp.max_ui_windows` (4) secondary windows may be declared; the excess warns and is ignored. `min_width`/`min_height` declare a content min-size floor the window itself enforces (macOS `contentMinSize`): the user's resize stops at the floor instead of the layout clamping and clipping panes below their declared minimums — the same fields exist on app.zon windows and `ShellWindow`, and the startup window applies its declaration at the host create like `titlebar`. 0 (the default) leaves the axis at the platform's own minimum.
Input from any window dispatches Msgs through that window's own tree with its window identity. `WindowDescriptor.close_policy` accepts `.quit` (the default) or `.hide`, with the same behavior and platform safeguards as manifest windows. A USER close under `.quit` dispatches the descriptor's `on_close` Msg — the dismissal precedent applied to windows: the window is already gone as an optimistic echo, the model clears its open flag in `update` (or keeps declaring the window and the next rebuild brings it back — source wins). Under `.hide`, the window stays alive with its views, `on_close` does not fire, and `showWindow(label)` brings it back. A close the model itself initiated (it stopped declaring the window) never echoes a Msg. At most `UiApp.max_ui_windows` (4) secondary windows may be declared; the excess warns and is ignored. `min_width`/`min_height` declare a content min-size floor the window itself enforces (macOS `contentMinSize`): the user's resize stops at the floor instead of the layout clamping and clipping panes below their declared minimums — the same fields exist on app.zon windows and `ShellWindow`, and the startup window applies its declaration at the host create like `titlebar`. 0 (the default) leaves the axis at the platform's own minimum.
Markup deliberately binds ONE window's content: there is no `window` element in the closed grammar, because windows are app-shell concerns, not view-tree concerns. A markup-authored secondary window is a `canvas.CompiledMarkupView` whose `build` the `window_view` fn calls for the matching label.
@@ -527,7 +575,7 @@ ui.image(.{ .image = model.chart_image, .width = 120, .height = 80, .semantics =
`fx.registerImage(id, width, height, rgba8)` registers already-decoded pixels (the runtime copies them; your buffer is free on return), `fx.registerImageBytes(id, bytes)` decodes through the platform codec first, and `fx.unregisterImage(id)` frees the slot. Re-registering an id replaces its pixels and every view repaints — GPU caches re-upload off the changed content fingerprint, no invalidation calls. For caches, mint fresh ids (effect-key style, monotonically increasing) and unregister the evictee — never re-key different content onto a live id. Outside `UiApp`, the same registry is `Runtime.registerCanvasImage`/`registerCanvasImageBytes`/`unregisterCanvasImage`.
Capacities are fixed and loud (`canvas_limits`): `max_registered_canvas_images` slots (16) of `max_registered_canvas_image_pixel_bytes` each (1 MiB — 512×512 RGBA8, avatar/icon scale), with `error.ImageRegistryFull`, `error.ImageTooLarge`, `error.ImageDecodeFailed`, and `error.UnsupportedService` (a platform without a codec) never silent. Registered images render everywhere the canvas does: live presentation (GPU packet and software paths), `renderCanvasScreenshot`, and automation screenshots. A draw referencing an id that is not (or no longer) registered skips — a pure view cannot fail presentation with a transient loading state. In tests, the null platform's `image_decode` flag enables a deterministic decoder for the strict PNG subset `canvas.png.writeRgba8` emits, so raw RGBA fixtures exercise the full decode→register→draw path without bundling a codec.
Capacities are fixed and loud (`canvas_limits`): 16 slots with a 1 MiB decoded-pixel target by default. Encoded photos decode aspect-preservingly to fit, while raw `fx.registerImage` pixels remain strict. Image-centric apps may raise the startup-frozen target through app.zon `.images.max_image_pixel_bytes`, up to 8 MiB; storage is lazy per used slot, but filling all 16 ceiling-sized slots is a declared 128 MiB high-water. Every encoded entry point, including direct `fx.registerImageBytes`, shares the flat 8 MiB source bound. `error.ImageRegistryFull`, `error.ImageTooLarge` (encoded source, raw pixels, or a codec-contract violation), `error.ImageDecodeFailed`, and `error.UnsupportedService` are never silent. Registered images render everywhere the canvas does: live presentation, screenshots, and automation. A missing id simply draws its fallback. In tests, the null platform's deterministic strict-PNG decoder also pins exact decode-to-fit dimensions and pixels.
## Rich text: inline spans and markdown
@@ -699,10 +747,10 @@ Zig-built views get the same discipline at tree level: `canvas.expectA11yAuditSw
The model-contract artifact — `zig-out/model-contract.zon`, a reflection of your `Model`/`Msg` — is refreshed by `native test` in every app shape (apps that own their build can also run `zig build model-contract` directly). With that artifact fresh, `native check` verifies markup against your app's actual surface — no app compile in the loop:
- View → model: every binding path, `for each` iterable, `key` field, message tag, and payload type must exist on the model with the right shape, and expressions type-check with the real binding types — `{count > 'a'}` is an error naming `count` and its Zig type. Unknown names get a did-you-mean over your model's actual fields.
- Model → view: model fields, query fns, and `Msg` tags that no view binds or dispatches are reported as **warnings** — state only `update`/fx logic touches is legitimate, so declare it: `pub const view_unbound = .{ "next_id" };` on `Model` or `Msg` opts names out. `--strict` promotes the warnings to failures.
- View → model: every binding path, `for each` iterable, `key` field, message tag, and payload type must exist on the model with the right shape, and expressions type-check with the real binding types — `{count > 'a'}` is an error naming `count` and its core type. Unknown names get a did-you-mean over your model's actual fields.
- Model → view: model fields, derived helpers/query functions, and `Msg` tags that no view binds or dispatches are reported as **warnings**. State only `update` or effects logic touches is legitimate, so opt it out in the core's vocabulary: TypeScript declares `export const viewUnbound = ["nextId", "tick"] as const` in `src/core.ts`; Zig declares `pub const view_unbound = .{ "next_id", "tick" };` on `Model` or `Msg`. Use the names exactly as that core authored them. `--strict` promotes the warnings to failures.
- Template args are part of a template's interface: the kinds of use-site arguments flow into the template body (a string passed into a `width` arg fails at the use), slot content checks in the consumer's scope, and the check follows the whole `<import>` closure.
A missing or stale artifact (the artifact carries a hash of your Zig sources) degrades to structural checking with one loud line ("model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks") — never a false pass; the compiled engine still enforces the same contract at build time, and a conformance suite holds the two checkers to identical accept/reject sets. Model state consumed only by a Zig-built view needs `view_unbound` too — the markup checker cannot see Zig view reads.
A missing or stale artifact (the artifact carries a hash of the app's core sources) degrades to structural checking with one loud line ("model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks") — never a false pass; the compiled engine still enforces the same contract at build time, and a conformance suite holds the two checkers to identical accept/reject sets. Model state consumed only by a Zig-built view needs `view_unbound` too — the markup checker cannot see Zig view reads.
For the component catalog and styling model, see [Built-in Components](/docs/built-in-components). For dropping down to the programmatic API, the `canvas.Ui(Msg)` builder produces exactly the same trees the markup compiles to.
+64 -2
View File
@@ -86,6 +86,13 @@ The manifest drives packaging metadata:
.platforms = .{ "macos", "linux" },
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
.dmg = .{
.background = "assets/dmg-background.png",
.window_width = 660,
.window_height = 400,
.app_position = .{ .x = 166, .y = 182 },
.applications_position = .{ .x = 486, .y = 182 },
},
}
```
@@ -125,6 +132,10 @@ The manifest drives packaging metadata:
<td><code>platforms</code></td>
<td>Which platform packages to generate</td>
</tr>
<tr>
<td><code>dmg</code></td>
<td>Optional macOS disk-image branding and Finder layout; defaults already produce a styled drag-to-Applications image</td>
</tr>
</tbody>
</table>
@@ -148,6 +159,55 @@ macOS app bundles declare `LSMinimumSystemVersion` as `11.0`. File associations
See [Code Signing](/docs/packaging/signing) for signing, notarization, and DMG creation.
### DMG archives
Add <code>--archive</code> to turn the packaged app into the disk image users download:
```bash
native build
native package --target macos --archive
```
Without any extra configuration, Native creates a 660×400 Finder window with a quiet generated background and arrow, packages matching 1× and 2× representations for crisp Retina rendering, positions the app and an `/Applications` alias on either side, hides the Finder chrome, and compresses the result as a `.dmg`. The package diagnostic prints both the `.app` and `.dmg` paths.
Customize the presentation in `app.zon`:
```zig
.dmg = .{
.volume_name = "My App",
.background = "assets/dmg-background.png",
.window_width = 720,
.window_height = 440,
.icon_size = 144,
.app_position = .{ .x = 180, .y = 210 },
.applications_position = .{ .x = 540, .y = 210 },
.applications_link = true,
},
```
The window dimensions are the usable background canvas, excluding Finder's title bar. Positions are icon centers measured from the canvas's top-left corner. The background must be a project-relative PNG, JPEG, or TIFF at the configured window size; Finder displays it at its natural size. `native validate` and packaging reject malformed images or dimensions that do not match the configured canvas. For Retina artwork, put a double-sized sibling next to it using the `@2x` convention—for example, `dmg-background.png` at 720×440 and `dmg-background@2x.png` at 1440×880. Native discovers the pair, verifies that the sibling is exactly double-sized, and packages both representations. A prebuilt multi-resolution TIFF also works.
Omitting `background` keeps Native's generated Retina-aware gradient and draws the arrow between the configured app and Applications positions. Set `applications_link = false` only for a disk image that is not meant to use the conventional drag-to-install flow.
For complete control over which Finder items appear and where they sit, replace the fixed app/Applications pair with `items`:
```zig
.dmg = .{
.background = "assets/dmg-background.png",
.window_width = 760,
.window_height = 480,
.icon_size = 112,
.items = .{
.{ .kind = "app", .position = .{ .x = 150, .y = 180 } },
.{ .kind = "applications", .position = .{ .x = 610, .y = 180 } },
.{ .kind = "file", .path = "README.pdf", .name = "Read Me.pdf", .position = .{ .x = 250, .y = 370 } },
.{ .kind = "link", .path = "/Library/QuickLook", .name = "QuickLook", .position = .{ .x = 510, .y = 370 } },
},
},
```
An explicit list must contain exactly one `app`; its optional `name` changes only the bundle name shown in the DMG. `applications` creates the `/Applications` alias. `file` copies a project-relative file or directory, with an optional display `name`, while `link` creates a named symbolic link to an absolute path. When `items` is present, it replaces `app_position`, `applications_position`, and `applications_link`. The generated background draws its arrow whenever the list includes both `app` and `applications`.
## Linux
### Package structure
@@ -272,12 +332,14 @@ native validate app.zon
In addition to `native package --target <platform>`, the CLI provides shortcut commands:
```bash
native package-windows [--output path] [--binary path]
native package-linux [--output path] [--binary path]
native package-windows [--output path] [--binary path] [--service-binary path]
native package-linux [--output path] [--binary path] [--service-binary path]
native package-ios [--output path] [--binary path]
native package-android [--output path] [--binary path]
```
The desktop shortcuts use an explicit `--service-binary` when supplied; otherwise, service-bearing projects discover the normal `zig-out/bin/<app>_services[.exe]` build output just like `native package`.
## Platform targets
<table>
+7 -6
View File
@@ -83,11 +83,11 @@ The framework repository includes a `zig build notarize` helper for local releas
zig build notarize
```
Generated apps should use `native package --target macos --signing identity ...` unless they add their own `notarize` build step. This helper does not invoke `xcrun notarytool` directly. After the signed package is created, submit it for notarization manually:
Generated apps should use `native package --target macos --signing identity ... --archive` unless they add their own `notarize` build step. This helper does not invoke `xcrun notarytool` directly. After the signed DMG is created, submit and staple it manually (use the archive path printed by the package command):
```bash
xcrun notarytool submit zig-out/package/your-app.zip --apple-id "you@example.com" --team-id "TEAMID" --password "@keychain:AC_PASSWORD" --wait
xcrun stapler staple zig-out/package/your-app.app
xcrun notarytool submit zig-out/package/your-app-1.0.0-macos-ReleaseFast.dmg --apple-id "you@example.com" --team-id "TEAMID" --password "@keychain:AC_PASSWORD" --wait
xcrun stapler staple zig-out/package/your-app-1.0.0-macos-ReleaseFast.dmg
```
## Chromium apps
@@ -97,8 +97,7 @@ Chromium packages include `Chromium Embedded Framework.framework` inside the `.a
```bash
native cef install --version <pinned-version>
zig build
native package --target macos --signing identity --identity "Developer ID Application: Your Name"
hdiutil create -volname "Your App" -srcfolder zig-out/package/your-app.app -ov -format UDZO zig-out/package/your-app.dmg
native package --target macos --signing identity --identity "Developer ID Application: Your Name" --archive
```
Use `.web_engine = "chromium"` and `.cef = .{ .dir = "third_party/cef/macos", .auto_install = false }` in `app.zon` for the normal signing path. `-Dweb-engine`, `--web-engine`, `-Dcef-dir`, and `--cef-dir` remain available for temporary overrides.
@@ -110,9 +109,11 @@ If Gatekeeper rejects the app, check that the CEF framework is present in `Conte
Create a distributable disk image:
```bash
zig build dmg
native package --target macos --archive
```
This creates the conventional drag-to-Applications presentation by default: a generated background and arrow, positioned app and Applications icons, hidden Finder chrome, and a compressed final image. Customize it through the [`dmg` fields in `app.zon`](/docs/packaging#dmg-archives).
## Entitlements
The project includes `assets/native-sdk.entitlements` as a starting point. Customize it for your app's needs (e.g. network access, file system access, camera).
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("persistence");
export default function PersistenceLayout({ children }: { children: React.ReactNode }) {
return children;
}
+78
View File
@@ -0,0 +1,78 @@
# Model Persistence
Model persistence stores the TypeScript core's committed `Model` without giving `update` filesystem access or making the app own a serialization loop. Declare the capability and its monotonic schema version in `app.zon`:
```zig:app.zon
.capabilities = .{ "persist" },
.persist = .{
.version = 1,
.debounce_ms = 500,
.restore = .{
.ok = "restored",
.none = "fresh_boot",
.err = "restore_failed",
},
},
```
The three route names refer to `Msg` arms. `ok` and `none` are void arms; `err` carries one `Uint8Array` field containing one closed reason: `corrupt`, `version_unknown`, `migrate_failed`, `io_failed`, or `rejected`.
```ts:src/core.ts
import { Cmd } from "@native-sdk/core";
export interface Model {
readonly draft: Uint8Array;
readonly saves: number;
}
export type Msg =
| { readonly kind: "edited"; readonly draft: Uint8Array }
| { readonly kind: "restored" }
| { readonly kind: "fresh_boot" }
| { readonly kind: "restore_failed"; readonly reason: Uint8Array };
export const viewUnbound = ["restored", "fresh_boot", "restore_failed"] as const;
export function initialModel(): Model {
return { draft: new Uint8Array(0), saves: 0 };
}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "edited":
return [{ ...model, draft: msg.draft, saves: model.saves + 1 }, Cmd.persist()];
case "restored":
case "fresh_boot":
case "restore_failed":
return model;
}
}
```
`Cmd.persist()` snapshots the model from that committed update. The command carries no model bytes; the compiled core exposes its generated canonical encoder to the host. The host coalesces requests on a trailing edge, performs filesystem work off the update thread, keeps at most one write in flight, and force-flushes the pending tail during backgrounding and graceful shutdown. A failed write dispatches the configured `err` route, just like a failed boot restore. The default debounce is 500 ms; `debounce_ms` accepts 060,000.
Snapshots live in the platform app-data directory as `snapshot.nsd`. Installation is atomic: the engine writes and syncs a temporary file, renames it over the primary, and keeps one structurally valid prior generation as `snapshot.nsd.bak`. A corrupt or torn primary falls back to that backup. The generated body is little-endian and uses tagged, length-delimited Model fields; snapshot bodies are bounded at 16 MiB, independently of the raw file-effect limits.
## Boot and replay
Before the first frame, the engine restores the canonical model and dispatches exactly one configured route. `restored` observes the restored model; `fresh_boot` means neither generation exists. Restore and migration failures leave the initial model in place and dispatch `restore_failed` with the reason bytes.
The restore result crosses the ordinary effect journal boundary. Recording stores non-empty snapshot bytes in the session blob store; replay feeds those bytes back without reading or writing the live app-data directory. `Cmd.persist()` remains on the replay command stream for fingerprint parity, but its host binding is a no-op.
## Schema versions and migration
Increase `.persist.version` whenever the `Model` shape changes, and never reuse a version. `native check` verifies that the configured `ok` and `none` routes name void `Msg` arms and `err` names a one-`Uint8Array`-field arm; `native dev --core` runs the same fence before starting its virtual host. Check also remembers the last accepted version/fingerprint pair under `.native/cache` and reports NS1068 when the shape changes without a bump or the version moves backward. The snapshot header carries that app version, the generated model-only shape fingerprint, and the compiler's snapshot-format version, so a cold checkout still fails closed at runtime: a same-version shape mismatch reports `corrupt`, and a snapshot from a future app version reports `version_unknown`. That older binary also refuses subsequent writes with `version_unknown`, preserving the newer snapshot across a rollback.
To accept an older version, export the pure migration hook from `src/core.ts`:
```typescript
export function migrate(snapshot: Uint8Array, fromVersion: number): Model {
// Decode the versioned legacy bytes and construct the current Model.
// Throw a subset value when the legacy bytes cannot be migrated.
return decodeLegacyModel(snapshot, fromVersion);
}
```
The checker requires the exact `(Uint8Array, number) => Model` shape. A successful migration is encoded in the current format, installed as the new snapshot, restored before delivery, and journaled as an ordinary successful restore. A thrown value, missing hook, or invalid result reports `migrate_failed`.
Version 1 persists the whole model. Do not put tokens or passwords in it; declare the `credentials` capability and permission, store them with [`Cmd.credentials.set`](/docs/capabilities), and consume get-result bytes without retaining them in Model. Credential recording is redacted and replay receives only a deterministic same-length placeholder. Raw `Cmd.readFile` and `Cmd.writeFile` remain the escape hatch for user-visible files, exports, and blobs—not the default model store.
+19 -2
View File
@@ -78,6 +78,14 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts
<td><Tier tier="none" /></td>
<td><Tier tier="none" /></td>
</tr>
<tr>
<td>System notifications</td>
<td><Tier tier="full" note="same-id replacement and a native action button; actions dispatch while the app is running" fn={11} /></td>
<td><Tier tier="caveats" note="one in-flight legacy balloon, so every new notification replaces the current one; there is no custom button, and clicking the notification invokes its labeled action" fn={11} /></td>
<td><Tier tier="full" note="same-id replacement and a native action button; actions dispatch while the app is running" fn={11} /></td>
<td><Tier tier="none" /></td>
<td><Tier tier="none" /></td>
</tr>
<tr>
<td>Menu-bar app lifecycle (<code>close_policy = "hide"</code>)</td>
<td><Tier tier="full" note="close hides, the Dock reopen re-shows, tray Open/Quit drive showWindow/quitApp" /></td>
@@ -128,9 +136,10 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts
5. App menus and context menus are native on all three desktops: macOS presents `NSMenu`, Windows `TrackPopupMenu` (the tray menu's popup path), Linux `GtkPopoverMenu`. Hosts without a native context-menu presenter — the mobile toolkit hosts and embed hosts today — present the same declared context menu as an anchored canvas surface at the click point; authors declare one menu either way.
6. Tray support is implemented on macOS (`NSStatusItem`) and Windows; Linux tray calls return `UnsupportedService` until a portable status-notifier implementation is selected.
7. Web engines only apply to apps that embed web content; native-rendered apps carry none. The system WebView is the default engine on every desktop platform; bundled Chromium through CEF is available on macOS only, and Linux/Windows Chromium builds fail early instead of silently substituting an engine. The Windows system engine is WebView2: its Evergreen runtime ships with Windows 11 and current Windows 10 (older machines need the runtime installer; `native doctor` checks for it), and the build stages the vendored loader next to the executable automatically. See [Web Engines](/docs/web-engines). The mobile shell examples embed the platform WebView as the content workspace.
8. `native package` targets all five platforms: macOS gets a `.app` bundle (plus `zig build dmg`), Linux an install tree, Windows a distributable directory with a per-user file-type registration script, iOS a complete generated Xcode project — toolkit host sources, Info.plist, asset catalog, shared scheme, and the device-slice embed library — that `xcodebuild archive` builds with zero edits (code signing stays a manual step, like notarization), and Android a complete generated host project whose debug APK assembles with zero edits, directly with the SDK's build tools (store signing keys stay a manual step). Every platform's icons generate from one square source image. See [Packaging](/docs/packaging).
8. `native package` targets all five platforms: macOS gets a `.app` bundle plus a styled drag-to-Applications DMG with `--archive`, Linux an install tree, Windows a distributable directory with a per-user file-type registration script, iOS a complete generated Xcode project — toolkit host sources, Info.plist, asset catalog, shared scheme, and the device-slice embed library — that `xcodebuild archive` builds with zero edits (code signing stays a manual step, like notarization), and Android a complete generated host project whose debug APK assembles with zero edits, directly with the SDK's build tools (store signing keys stay a manual step). Every platform's icons generate from one square source image. See [Packaging](/docs/packaging).
9. macOS signing supports `adhoc` and `identity` modes with entitlements; notarization is submitted manually with the platform tools after packaging. No signing tooling exists yet for the other platforms. See [Code Signing](/docs/packaging/signing).
10. The automation server is a file-based protocol the runtime serves on every desktop platform: snapshots, assertions, synthetic input, screenshots, record/replay. Engine screenshots render through the deterministic CPU reference renderer on every platform, so they are byte-comparable across hosts. Mobile exposes accessibility snapshots and actions through the embed ABI; the iOS and Android toolkit hosts serve the same file-based protocol inside the app's data container when launched with automation enabled.
11. Desktop notification identifiers are app-local replacement keys. macOS and Linux expose the requested action as a native button. The current Windows host uses the in-box `Shell_NotifyIcon` balloon surface, which has no app-defined action button; it includes the label in the notification and treats a notification click as activation. All three deliver action commands only to the process that issued the notification. Linux notification shells may D-Bus-activate an exited app when a persisted notification is clicked, but its process-scoped action token is stale and cannot dispatch a command into the new runtime; macOS and Windows cold-start command activation is not wired.
## Desktop Hosts
@@ -298,6 +307,14 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts
<td>Unsupported</td>
<td>Supported on Windows 10 2004+ (process-scoped WASAPI loopback of this app only, probed live)</td>
</tr>
<tr>
<td>Microphone + system audio capture</td>
<td>Supported (AVAudioEngine microphone; ScreenCaptureKit system mix on macOS 13+; user consent required)</td>
<td>Unsupported</td>
<td>Unsupported</td>
<td>Unsupported</td>
<td>Supported (default capture endpoint + WASAPI render loopback)</td>
</tr>
</tbody>
</table>
@@ -328,7 +345,7 @@ JavaScript can query the same support model through the built-in bridge when `js
const hasTray = await window.zero.platform.supports("tray");
```
Feature names match the Zig `PlatformFeature` enum and the TypeScript `NativeSdkPlatformFeature` union. JavaScript callers may use either snake_case names such as `native_views` or camelCase aliases such as `nativeViews`. Unsupported operations still reject explicitly if called; support checks are intended for choosing UI affordances before making those calls.
Feature names match the Zig `PlatformFeature` enum and the TypeScript `NativeSdkPlatformFeature` union. JavaScript callers may use either snake_case names such as `native_views`, `microphone_capture`, and `system_audio_capture` or camelCase aliases such as `nativeViews`, `microphoneCapture`, and `systemAudioCapture`. Unsupported operations still reject explicitly if called; support checks are intended for choosing UI affordances before making those calls.
## Native Surfaces
+1 -1
View File
@@ -7,7 +7,7 @@ Native SDK is the complete toolkit for building beautiful native desktop applica
## Prerequisites
- macOS 11 or newer, Linux, or Windows
- Node.js 22.15+ (on the 23 line: 23.5+) for the default TypeScript scaffold — the TypeScript frontend (the checker) and the core dev loop run under it at build and dev time, and the external core compiler that builds the checked core to native code ships as an exact-pinned dependency of the CLI; the binary you ship carries no JS runtime. A Zig-core app (`--template zig-core`) does not need node.
- Node.js 24+ for the default TypeScript scaffold — the TypeScript frontend (the checker), scriptc compiler, and core dev loop run under it at build and dev time; scriptc 0.0.33 uses Node 24's compile cache to accelerate repeated invocations. The binary you ship carries no JS runtime. A Zig-core app (`--template zig-core`) needs Node only when it declares relational SQLite, whose schema checker and migration generator run at build time.
## Get the CLI
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("record-store");
export default function RecordStoreLayout({ children }: { children: React.ReactNode }) {
return children;
}
+127
View File
@@ -0,0 +1,127 @@
# Record Store
The record store persists independent byte records without giving `update` a database handle or making the app own a file format. It is the right fit for caches, message history, and document-sized values that grow or change one record at a time. Declare the build capability in `app.zon`:
```zig:app.zon
.capabilities = .{ "store" },
```
`"store"` links the shared SQLite engine and opens one engine-owned `store.db` in the app-data directory. Apps name keys, never paths or SQL. The common desktop runner and the iOS and Android hosts install that data directory before the first app effect. Apps without either storage capability do not link SQLite, and `native check` warns when the `"store"` declaration and `Cmd.store.*` calls disagree. The relational tier's `"sqlite"` capability selects the same engine object, so an app declaring both still links SQLite once.
## A saved draft
Every operation is a command. The committed model changes first; the result returns later as an ordinary `Msg`.
```ts:src/core.ts
import { Cmd } from "@native-sdk/core";
export interface Model {
readonly draft: Uint8Array;
readonly loaded: boolean;
}
export type Msg =
| { readonly kind: "load" }
| { readonly kind: "loaded"; readonly result: Uint8Array }
| { readonly kind: "edited"; readonly draft: Uint8Array }
| { readonly kind: "saved" }
| { readonly kind: "store_failed"; readonly reason: Uint8Array };
export const viewUnbound = ["loaded", "saved", "store_failed"] as const;
export function initialModel(): Model {
return { draft: new Uint8Array(0), loaded: false };
}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "load":
return [model, Cmd.store.get("draft/current", {
key: "load-draft", ok: "loaded", err: "store_failed",
})];
case "loaded":
// A get result starts with 1 for a hit and 0 for a miss. The value
// follows the hit byte, so an empty value is distinct from absence.
return msg.result[0] === 1
? { draft: msg.result.subarray(1), loaded: true }
: { ...model, loaded: true };
case "edited":
return [{ ...model, draft: msg.draft }, Cmd.store.set(
"draft/current",
msg.draft,
{ key: "save-draft", ok: "saved", err: "store_failed" },
)];
case "saved":
case "store_failed":
return model;
}
}
```
Reissuing the same route `key` replaces the older in-flight operation, and `Cmd.cancel(key)` cancels it silently. Distinct commands issued in one commit are performed in command-stream order: a synchronous read waits for writes that precede it. A get in a later commit also observes an earlier successful set.
## Zig-core parity
Zig cores use the same runtime-owned database and result envelope. The common app runner installs the binding before the app's first effect; no Zig entry point resolves a path or opens SQLite.
```zig
const Msg = union(enum) {
store_result: native_sdk.EffectHostResult,
};
const Effects = native_sdk.Effects(Msg);
fn loadDraft(fx: *Effects) void {
fx.storeGet(.{
.key = 1,
.record_key = "draft/current",
.on_result = Effects.hostMsg(.store_result),
});
}
```
`Effects.storeSet`, `storeGet`, `storeDelete`, `storeScan`, and `storeSetMany` mirror the TypeScript operations. Their `EffectHostResult` carries `key`, `ok`, and `bytes`; get and scan use exactly the framing described below.
## Operations and bounds
<table>
<thead>
<tr>
<th>Command</th>
<th>Behavior</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Cmd.store.set(key, bytes, route)</code></td>
<td>Insert or replace one value. Keys are non-empty UTF-8 up to 512 bytes; values are at most 1 MiB.</td>
</tr>
<tr>
<td><code>Cmd.store.get(key, route)</code></td>
<td>Return <code>[1][value...]</code> for a hit or <code>[0]</code> for a miss through the ok arm.</td>
</tr>
<tr>
<td><code>Cmd.store.delete(key, route)</code></td>
<td>Delete one value. A missing key succeeds.</td>
</tr>
<tr>
<td><code>Cmd.store.scan(prefix, options, route)</code></td>
<td>Return a byte-lexicographic prefix page. <code>limit</code> defaults to 100 and is capped at 256; pass the returned next-key bytes as <code>after</code> (a known literal key may be passed as a string).</td>
</tr>
<tr>
<td><code>Cmd.store.setMany(entries, route)</code></td>
<td>Insert or replace 164 records atomically, with an 8 MiB encoded batch bound.</td>
</tr>
</tbody>
</table>
A scan page is little-endian framed bytes: `count u32`, then `count` repetitions of `key_length u32`, key bytes, `value_length u32`, value bytes, followed by `next_length u32` and the next-key bytes. An empty next key ends pagination. Pages stop at record boundaries; records are never truncated.
All error arms receive one closed reason as UTF-8 bytes: `io_failed`, `over_bound`, `bad_key`, `rejected`, or `busy`. Cache misses use the get ok arm because absence is an expected lookup result. `setMany` validates the entire batch before its transaction, so an invalid entry changes nothing.
## Replay and the virtual host
Store results use the ordinary effect journal. Session replay feeds the recorded result and never opens the live database. Zig full-loop tests opt into one hermetic SQLite database per harness with `TestHarness().createWithRecordStore(allocator, surface)`; it is bound before `harness.start(app)` and closed by `harness.destroy(allocator)`. `native dev --core` performs the same API against a process-local map that survives its simulated `{"restart": true}` command.
Use [Model Persistence](/docs/persistence) when the whole in-memory model is the unit you save. Use the record store when records grow independently. Use raw file effects only for user-visible files, exports, or blobs larger than the record bound; relational queries and secondary indexes belong in the SQL tier rather than this API.
The repository's [record-store example](https://github.com/vercel-labs/native/tree/main/examples/record-store) exercises all five commands from a TypeScript core and Native markup view.
+1 -1
View File
@@ -149,7 +149,7 @@ The runtime dispatches `LifecycleEvent` values through your `event_fn`:
- **`frame`** -- a frame has been requested (for animations or state updates)
- **`stop`** -- the app is shutting down
Native file drops dispatch `Event.files_dropped` to `event_fn`. Apps with WebView content also receive `app:activate`, `app:deactivate`, and `drop:files` on each trusted `window.zero` instance:
Native file drops dispatch `Event.files_dropped` to `event_fn`; `UiApp` maps them through `Options.on_drop`, and TypeScript cores export `dropMsg(drop: FileDropEvent)` (see [Native UI: File drops](/docs/native-ui#file-drops)). Apps with WebView content also receive `app:activate`, `app:deactivate`, and `drop:files` on each trusted `window.zero` instance:
```ts
window.zero.on("drop:files", (event) => {
+7 -3
View File
@@ -39,7 +39,7 @@ Every app declares `permissions` and `capabilities` in `app.zon` — the runtime
</tr>
<tr>
<td><code>filesystem</code></td>
<td>File system access from bridge commands</td>
<td>Raw file effects and bridge commands outside this app's resolved data/config/cache/state/logs/temp roots; symlinks are resolved before the runtime check</td>
</tr>
<tr>
<td><code>clipboard</code></td>
@@ -61,6 +61,10 @@ Every app declares `permissions` and `capabilities` in `app.zon` — the runtime
<td><code>microphone</code></td>
<td>Microphone access</td>
</tr>
<tr>
<td><code>system_audio</code></td>
<td>System-output audio capture</td>
</tr>
<tr>
<td><code>location</code></td>
<td>Location services</td>
@@ -195,7 +199,7 @@ The Native SDK provides built-in commands for app command routing (`native-sdk.c
`js_window_api` exposes the JavaScript command, window, view, WebView, and platform support helpers, but it does not bypass security. Command routing (`native-sdk.command.invoke`) must come from an allowed origin and have the `command` permission when runtime permissions are configured. Generic native view commands (`native-sdk.view.create`, `list`, `update`, `setFrame`, `setVisible`, `focus`, `focusNext`, `focusPrevious`, `close`) require the `view` permission. Platform support queries, window commands (`native-sdk.window.list`, `create`, `focus`, `close`), and WebView commands (`native-sdk.webview.create`, `list`, `setFrame`, `navigate`, `setZoom`, `setLayer`, `close`) require the `window` permission. The legacy `window` grant is still accepted for command and view helpers for compatibility. View and WebView commands can only target the window that sent the bridge message. WebView URLs must also be allowed by `security.navigation.allowed_origins`, and child WebViews receive `window.zero` only when created with `bridge: true`.
For broader control, use an explicit `builtin_bridge` policy. When you choose this path, list every built-in command your app calls. Dialog commands (`native-sdk.dialog.openFile`, `saveFile`, `showMessage`), OS commands (`native-sdk.os.openUrl`, `showNotification`, `revealPath`, `addRecentDocument`, `clearRecentDocuments`), clipboard commands (`native-sdk.clipboard.readText`, `writeText`, `read`, `write`), and credential commands (`native-sdk.credentials.set`, `get`, `delete`) are **always default-deny** and require an explicit `builtin_bridge` policy with the command listed. `native-sdk.os.openUrl` also requires `security.navigation.external_links` to allow the target URL:
For broader control, use an explicit `builtin_bridge` policy. When you choose this path, list every built-in command your app calls. Dialog commands (`native-sdk.dialog.openFile`, `saveFile`, `showMessage`), OS commands (`native-sdk.os.openUrl`, `showNotification`, `revealPath`, `addRecentDocument`, `clearRecentDocuments`), clipboard commands (`native-sdk.clipboard.readText`, `writeText`, `read`, `write`), and credential commands (`native-sdk.credentials.set`, `get`, `delete`) are **always default-deny** and require an explicit `builtin_bridge` policy with the command listed. `native-sdk.os.openUrl` also requires `security.navigation.external_links` to allow the target URL. A `showNotification` request with `actionLabel` / `actionCommand` additionally requires `native-sdk.command.invoke` and its `command` permission for the same origin:
```zig
.builtin_bridge = .{
@@ -323,7 +327,7 @@ External links are denied by default. To open links in the system browser, opt i
Do not allow broad external patterns for pages that can be influenced by remote content.
The same policy gates `runtime.openExternalUrl(...)` and `window.zero.os.openUrl(...)`. A bridge grant for `native-sdk.os.openUrl` is not enough by itself; the URL must also match `external_links.allowed_urls`.
The same policy gates `Cmd.openExternalUrl(...)`, `runtime.openExternalUrl(...)`, and `window.zero.os.openUrl(...)`. A bridge grant for `native-sdk.os.openUrl` is not enough by itself; the URL must also match `external_links.allowed_urls`.
## CSP guidance
+7 -3
View File
@@ -21,10 +21,14 @@ The CLI itself serves the full skill content. This is the layer the installed di
```sh
native skills list # list built-in skills
native skills get core # print a skill
native skills get native-ui # default app views and app loop
native skills get ts-core # default TypeScript app core
native skills get core --full # include its reference files
native skills get --all [--full] # print every skill
```
For a normal app created by `native init`, give the agent both `native-ui` and `ts-core`: TypeScript + Native markup is the primary authoring path. Load `core --full` when the task reaches shared runtime wiring, WebViews, packaging, or native capabilities; load `zig` for an existing Zig-core app or toolkit-extension work.
`skills get` prints the skill to stdout, so delivering one to an agent is a single redirect into wherever your agent loads skills from:
```sh
@@ -38,11 +42,11 @@ native skills get native-ui > .claude/skills/native-ui/SKILL.md
<dl>
<dt><code>core</code></dt>
<dd>The shared foundation: the mental model, project structure, <code>app.zon</code>, App and Runtime patterns, frontend integration, web engines, the JavaScript bridge, permissions, windows, WebViews, dialogs, packaging, debugging, and testing. <code>--full</code> appends its five reference files (project anatomy, App/Runtime patterns, frontend assets, web engines/packaging/debugging, bridge/security/native capabilities).</dd>
<dd>The shared foundation and task router: it establishes TypeScript + Native markup as the default, then covers project structure, <code>app.zon</code>, lower-level App and Runtime patterns, frontend integration, web engines, the JavaScript bridge, permissions, windows, WebViews, dialogs, packaging, debugging, and testing. <code>--full</code> appends its five reference files.</dd>
<dt><code>native-ui</code></dt>
<dd>Authoring native-rendered apps: <code>.native</code> markup views, bindings and message dispatch, <code>Model</code>/<code>Msg</code>/<code>update</code> on the <code>UiApp</code> loop, testing markup views, hot reload, and verifying the result through the automation harness.</dd>
<dd>The view half of primary app authoring: <code>.native</code> markup views, bindings and message dispatch, the <code>Model</code>/<code>Msg</code>/<code>update</code> loop, testing markup views, hot reload, and verification through the automation harness.</dd>
<dt><code>ts-core</code></dt>
<dd>Authoring <a href="/docs/typescript">TypeScript app cores</a>: the app-core subset, every checker rule by ID with its idiomatic fix, text-is-bytes, the full <code>Cmd</code>/<code>Sub</code> effect vocabulary, and the node dev loop.</dd>
<dd>The logic half of primary app authoring: <a href="/docs/typescript">TypeScript app cores</a>, the app-core subset, every checker rule by ID with its idiomatic fix, text-is-bytes, the full <code>Cmd</code>/<code>Sub</code> effect vocabulary, and the node dev loop.</dd>
<dt><code>automation</code></dt>
<dd>Driving and verifying a running app through <code>native automate</code>: snapshots, readiness waits, assertions, bridge round-trips, and smoke tests.</dd>
<dt><code>zig</code></dt>
+7
View File
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("sqlite");
export default function SqliteLayout({ children }: { children: React.ReactNode }) {
return children;
}
+123
View File
@@ -0,0 +1,123 @@
# Relational SQLite
The relational tier gives a model core an engine-owned SQLite database while keeping `update` pure. Add the capability in `app.zon`:
```zig:app.zon
.capabilities = .{ "sqlite" },
```
The runner opens `app.db` in the app-data directory, enables WAL and foreign keys, applies pending migrations, and keeps the path private. Apps that declare neither `"sqlite"` nor `"store"` do not link SQLite. The record store uses a separate `store.db`.
## Make the schema append-only
Migration files under `src/schema/` are the source of truth. Names are contiguous and monotonic:
```text
src/schema/0001_init.sql
src/schema/0002_add_tags.sql
```
Create the next file with:
```sh
native db new-migration add-tags
```
Use `STRICT` for ordinary generated-query tables so SQLite's storage classes agree with generated TypeScript types:
```sql:src/schema/0001_init.sql
CREATE TABLE folder (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
) STRICT;
CREATE TABLE note (
id INTEGER PRIMARY KEY,
folder_id INTEGER NOT NULL REFERENCES folder(id),
title TEXT NOT NULL
) STRICT;
```
`native check` applies the complete chain to a real in-memory SQLite database. It rejects gaps, edited published migrations, invalid SQLite, non-STRICT tables, and a schema change that is not represented by a new version. The accepted hashes are written to `src/schema/migrations.lock.json`; commit that file with the migrations so append-only validation has the same authority in every checkout and in CI. At launch the runtime compares the chain with `PRAGMA user_version` and applies all pending files in one transaction. A migration failure refuses the database open; a database newer than the binary is `version_unknown` and is never repaired or downgraded automatically.
`native db status` compares source and installed versions. `native db reset --yes` deletes the development `app.db`, WAL, and shared-memory files; the next launch reapplies the chain. Reset never accepts an arbitrary path.
## Declare checked queries
Put named statements in `src/queries.sql`:
```sql:src/queries.sql
-- name: notesInFolder :live
SELECT n.id, n.title
FROM note AS n
WHERE n.folder_id = :folder
ORDER BY n.id DESC;
-- name: moveNote :exec
UPDATE note SET folder_id = :to WHERE id = :id;
```
`native check` asks real SQLite to prepare every statement against the migrated schema. Missing tables or columns, invalid SQL, wrong read/write declarations, parameter mistakes, and invalid result shapes are reported at the `.sql` source with NS14xx diagnostics.
The accepted schema generates a flat API in `@native-sdk/core`:
- `Cmd.qNotesInFolder(params, route)` returns typed row pages.
- `Cmd.qMoveNote(params)` returns a typed transaction member.
- `Cmd.qTx([statement, ...], route)` commits all generated `:exec` members atomically.
- `Sub.qNotesInFolder(key, params, route)` exists because the query is marked `:live`.
- `NotesInFolderRow`, `NotesInFolderParams`, and `decodeNotesInFolderPage(bytes)` describe and decode its result.
The `q<Name>` spelling is intentionally flat: it stays inside the ahead-of-time core subset while retaining one-to-one names from `queries.sql`.
```ts:src/core.ts
import {
Cmd,
Sub,
decodeNotesInFolderPage,
utf8Bytes,
} from "@native-sdk/core";
export type Msg =
| { readonly kind: "move" }
| { readonly kind: "rows"; readonly page: Uint8Array }
| { readonly kind: "rows_done" }
| { readonly kind: "wrote" }
| { readonly kind: "db_failed"; readonly reason: Uint8Array };
// In update:
return [model, Cmd.qTx([
Cmd.qMoveNote({ id: 7, to: 2 }),
], { key: "move-note", ok: "wrote", err: "db_failed" })];
// In the rows arm:
const rows = decodeNotesInFolderPage(msg.page);
// In subscriptions(model):
return Sub.qNotesInFolder("folder-notes", { folder: model.folderId }, {
page: "rows",
done: "rows_done",
err: "db_failed",
});
```
SQLite `INTEGER` and `REAL` map to `number`; generated decoders reject an `INTEGER` outside JavaScript's exact ±(2^531) range. `TEXT` and `BLOB` map to `Uint8Array`, matching byte-honest Model storage. Generated parameters inferred from a TEXT column wrap bytes automatically. Use `dbText(bytes)` when a raw query—or a parameter whose storage class cannot be inferred, such as an FTS `MATCH` term—must bind bytes as SQLite TEXT rather than BLOB. Nullability comes from the schema.
## Live queries
A `:live` query runs when subscribed and runs again after a committed transaction touches one of its generated table dependencies. The runtime combines SQLite authorizer write targets with row-update notifications until commit, so `WITHOUT ROWID` tables and truncate-optimized deletes invalidate reliably; it coalesces invalidations once per command frame and never reruns an unrelated subscription. FTS5 shadow tables are included automatically. A `:live` declaration with no table dependency is rejected because it could never refresh.
Each delivery uses the same bounded page route as a one-shot query: zero or more `page` messages followed by `done`. Keep temporary pages in the Model and replace the visible result set on `done`. Changing a subscription's key, parameters, routes, SQL, or dependencies re-arms it; omitting the key cancels it. Dependencies are table-level in this release.
Every page and terminal crosses the session journal. Pages over 64 KiB spill into the journal's content-addressed blob store. Replay never opens SQLite: recorded one-shot and live results are fed back as ordinary Msg values, including repeated live deliveries.
## Raw escape hatch and bounds
`Cmd.db.query(sql, params, route)` and `Cmd.db.exec(statements, route)` remain available. A raw query is read-only and returns pages; one raw exec commits its entire 164 statement array as one transaction. `native check` warns when a raw query literal could instead be declared and checked.
Parameters accept `null`, finite `number`, literal `string`, `Uint8Array`, `dbText(bytes)`, and `boolean` (integer 0/1). A query accepts at most 64 parameters and 1 MiB of parameter bytes. A transaction accepts at most 8 MiB. SQL is capped at 64 KiB per statement. Results page at 256 rows or 256 KiB and never truncate a row; one result is capped at 8,192 rows or 8 MiB and rejects whole when it crosses either bound. Add `LIMIT` and keyset pagination for larger collections.
The binary page header is `column_count u32`, `row_count u32`, then length-prefixed UTF-8 column names. Row-major values use tags `0` NULL, `1` + signed little-endian i64, `2` + little-endian f64, `3` + length-prefixed TEXT, and `4` + length-prefixed BLOB.
The database boundary stays pathless. SQLite's authorizer denies `ATTACH`, `DETACH`, `VACUUM INTO`, and writes to engine-owned lifecycle PRAGMAs. Outcomes are closed: `constraint`, `busy`, `io_failed`, `corrupt`, `misuse`, `rejected`, and `cancelled`. Query keys replace; duplicate transaction keys reject loudly so a write is never silently lost.
Zig cores have first-class `Effects(Msg).dbQuery`, `dbExec`, `dbSubscribe`, and `dbUnsubscribe` operations over the same runtime. `native test` and `TestHarness().createWithRelationalStore` use real in-memory SQLite. See [`examples/relational-notes`](https://github.com/vercel-labs/native/tree/main/examples/relational-notes) for migrations, typed atomic writes, FTS5, page decoding, and two live queries together.
+6
View File
@@ -4,6 +4,12 @@ import { CodeToggle } from "@/components/code-toggle";
The model stores source-of-truth state only: the raw items, the current filter, the draft text. Everything the view shows that is computable from those — counts, sums, filtered lists, formatted strings — is derived at view time, never stored. This page collects the data-flow patterns that keep a Native SDK app honest; they hold in both core languages, and the samples show each where the expression differs.
## Pick one canonical store
For small in-memory apps, the Model owns the domain data and [model persistence](/docs/persistence) snapshots it. For relational apps, [SQLite](/docs/sqlite) owns the domain data; the Model holds view state plus the row pages most recently delivered by a query. Writes go out as one atomic `Cmd.db.exec`, and reads return as later Msg values—never as a database handle or a synchronous call inside `update`. Derived UI values still come from the Model's current rows, so the rule below does not change.
Secrets are the exception: tokens and passwords never belong in the Model, even temporarily. Store them with [`Cmd.credentials.set`](/docs/capabilities), retrieve them as an effect result, and consume the returned Msg bytes immediately to construct the next command (for example, an authenticated fetch) while returning a Model that does not retain them. That keeps secrets out of model snapshots, state fingerprints, and ordinary view data. Session recording redacts successful credential reads and replay supplies only a same-length placeholder.
## Derive, don't store
A cached derivable must be re-maintained in every `update` arm and goes stale the moment one is missed; a derived function cannot go stale.
+80 -1
View File
@@ -28,6 +28,85 @@ const MyApp = native_sdk.UiApp(Model, Msg);
.theme = app_runner.manifestThemePack(),
```
In a zero-config TypeScript app, export `themeState(model)` when pack, color scheme, or accent belongs in live app state. The generated launcher recognizes the helper and wires the stock-token path automatically:
```ts
import { type ThemeState } from "@native-sdk/core/events";
export type ThemePack = "house" | "geist";
export type ThemePreference = "system" | "light" | "dark";
export interface Model {
readonly theme: ThemePack;
readonly themePreference: ThemePreference;
readonly pinkAccent: boolean;
}
export function themeState(model: Model): ThemeState {
if (model.pinkAccent) {
return {
pack: model.theme,
colorScheme: model.themePreference,
accent: "#df2670",
};
}
return { pack: model.theme, colorScheme: model.themePreference };
}
```
`ThemeState` has three optional fields:
```ts
export type ThemeState = {
readonly pack?: "house" | "geist";
readonly colorScheme?: "light" | "dark" | "system";
readonly accent?: string; // exactly #rrggbb
};
```
Change the model through ordinary messages (for example, model-driven `toggle-button`s). The helper is evaluated after every committed update. Omitted fields inherit the next lower layer; omitted `colorScheme` and `"system"` both follow the OS. A malformed accent is a loud runtime teaching error, never a silent fallback. High contrast and reduced motion remain live OS inputs; high contrast suppresses both manifest and model accent overrides so accessibility wins.
The stock-theme precedence is:
<table>
<thead>
<tr>
<th>Axis</th>
<th>Highest to lowest precedence</th>
</tr>
</thead>
<tbody>
<tr>
<td>Complete tokens</td>
<td><code>tokens_fn</code> → static <code>tokens</code> → stock theme composition below</td>
</tr>
<tr>
<td>Pack</td>
<td><code>themeState.pack</code> → <code>app.zon theme</code> → <code>house</code></td>
</tr>
<tr>
<td>Color scheme</td>
<td>forced <code>themeState.colorScheme</code> → OS appearance</td>
</tr>
<tr>
<td>Accent</td>
<td><code>themeState.accent</code> → <code>app.zon theme_accent</code> → selected pack</td>
</tr>
<tr>
<td>High contrast / reduced motion</td>
<td>OS appearance (high contrast suppresses accent overrides)</td>
</tr>
<tr>
<td>Surface scale / text measurement</td>
<td>Runtime-stamped last</td>
</tr>
</tbody>
</table>
`themeState` deliberately controls canvas design tokens in v1. Native title bars and WebViews still follow the platform's effective appearance; forcing dark canvas content does not call `NSApp.appearance` or impose a scheme on embedded web content.
The earlier `themePack(model): "house" | "geist"` helper remains supported unchanged for apps that only switch packs. It preserves live OS scheme, manifest accent, high contrast, reduced motion, and surface scale. Export `themePack` or `themeState`, never both; the checker and adapter report that conflict as a teaching error.
Apps that derive their own tokens select the pack directly — `ThemeOptions.pack` is just another theme axis, exactly as switchable at runtime as the scheme:
```zig
@@ -97,7 +176,7 @@ pub fn brandTokens(scheme: canvas.ColorScheme, contrast: canvas.ColorContrast) c
}
```
Hand it to your app via `tokens_fn` (model-owned, follows the system scheme through your model) or `tokens` (fixed). The runtime stamps `pixel_snap.scale` and text measurement after your function runs, so never cache those.
Hand it to your app via `tokens_fn` (model-owned, follows the system scheme through your model) or `tokens` (fixed). These complete-token paths take precedence over `themeState`; the runtime stamps `pixel_snap.scale` and text measurement after your function runs, so never cache those.
## What themes cannot do
+131 -25
View File
@@ -37,6 +37,26 @@ On macOS, `title` renders the tray as a menu-bar extra: a titled `NSStatusItem`
<td><code>[]const TrayMenuItem</code></td>
<td><code>&.{}</code></td>
</tr>
<tr>
<td><code>presentation</code></td>
<td><code>TrayPresentation</code></td>
<td><code>.{}</code></td>
</tr>
<tr>
<td><code>activation_command</code></td>
<td><code>[]const u8</code></td>
<td><code>""</code></td>
</tr>
<tr>
<td><code>alternate_activation_command</code></td>
<td><code>[]const u8</code></td>
<td><code>""</code></td>
</tr>
<tr>
<td><code>open_command</code></td>
<td><code>[]const u8</code></td>
<td><code>""</code></td>
</tr>
</tbody>
</table>
@@ -76,6 +96,26 @@ On macOS, `title` renders the tray as a menu-bar extra: a titled `NSStatusItem`
<td><code>bool</code></td>
<td><code>true</code></td>
</tr>
<tr>
<td><code>detail</code></td>
<td><code>[]const u8</code></td>
<td><code>""</code></td>
</tr>
<tr>
<td><code>role</code></td>
<td><code>TrayItemRole</code></td>
<td><code>.command</code></td>
</tr>
<tr>
<td><code>key</code></td>
<td><code>[]const u8</code></td>
<td><code>""</code></td>
</tr>
<tr>
<td><code>modifiers</code></td>
<td><code>ShortcutModifiers</code></td>
<td><code>.{}</code></td>
</tr>
</tbody>
</table>
@@ -83,15 +123,61 @@ On macOS, `title` renders the tray as a menu-bar extra: a titled `NSStatusItem`
Use the runtime methods from app code:
- `runtime.createStatusItem(id, options)` -- create a status item under a stable non-zero id
- `runtime.updateStatusItemShell(id, shell)` -- update icon, tooltip, visibility, and activation/open commands in place
- `runtime.updateStatusItemMenu(id, items)` -- update one menu without recreating its status item
- `runtime.updateStatusItemPresentation(id, presentation)` -- update one live title and visual presentation
- `runtime.removeStatusItem(id)` -- remove only the identified item
- `runtime.createTray(options)` -- create or replace the tray icon
- `runtime.updateTrayMenu(items)` -- update menu items without recreating the tray
- `runtime.updateTrayTitle(title)` -- update only the live tray title
- `runtime.updateTrayPresentation(presentation)` -- update the live title and visual presentation
- `runtime.removeTray()` -- remove the tray icon
The lower-level `PlatformServices.createTray`, `updateTrayMenu`, and `removeTray` hooks are available for platform adapters. `Runtime` validates tray options before dispatch: non-separator menu items need a label, command-backed items need a unique non-zero `id`, and menus are capped at 32 items.
The singular `*Tray` methods are compatibility wrappers for reserved status-item id `1`. The lower-level `PlatformServices` surface exposes the same keyed methods for platform adapters. `Runtime` supports at most eight simultaneous status items and validates every menu independently: non-separator rows need a label, command-backed rows need a unique non-zero row `id`, and each menu is capped at 32 rows.
## TypeScript: model-derived status items
In a TypeScript app, export `statusItem(model)` from `src/core.ts`. The generated launcher installs it from the committed boot model and re-runs it after every model update. Shell, presentation, and menu are hashed independently, so changing the icon, tooltip, click hooks, title, width, tone, icon opacity, number style, or rows patches only that channel and never recreates the native status item.
```ts:src/core.ts
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: model.playing ? 72 : 48,
tone: model.failed ? "critical" : "normal",
iconOpacity: model.stale ? 0.5 : 1,
monospaced: true,
},
items: [
{ id: 10, label: model.today, command: asciiBytes(""), separator: false, enabled: false, detail: model.quota, role: "hero", 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: 3, label: utf8Bytes("Settings…"), command: asciiBytes("app.settings"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(","), modifiers: { primary: false, command: true, control: false, option: false, shift: false } },
],
};
}
```
`iconPath`, `tooltip`, `activationCommand`, `alternateActivationCommand`, and `openCommand` update live alongside presentation and rows. A normal click emits `activationCommand` and opens the menu; an Option-click emits `alternateActivationCommand` without opening it. Every menu open emits `openCommand`, which is useful for an on-demand refresh while the background cadence stays slow. These lifecycle hooks dispatch with `source = .tray`; empty commands disable them.
For multiple independent items, export `statusItems(model): readonly StatusItemDescriptor[]` instead. Each descriptor has the same shell, presentation, and row fields plus a stable non-zero `id` and live `visible` flag. Presence creates, absence removes, and changed fields patch only that identifier; menus update without replacing their `NSStatusItem`. Export either `statusItem` or `statusItems`, not both. This is the Vercel-shaped split: one spend indicator can appear or disappear while a separate control-menu item persists.
Rows use the exact `StatusItemMenuItem` record. `role` is `command`, `info`, `header`, `hero`, `agent`, or `context`; capable macOS hosts render the readout roles as native rich content while simpler hosts degrade them to text. `detail` carries secondary readout content, and `key` plus the five explicit `modifiers` fields declares a menu equivalent. Actionable rows need unique non-zero ids; separators conventionally use id 0 and empty byte fields. The menu may contain at most 32 rows. Map every row/click/open command to an ordinary message with `commandMsg(name): Msg | null`; no Zig `status_item_fn` glue is needed.
Use `utf8Bytes` for titles, labels, tooltips, and details; it preserves characters such as `…`, `·`, and emoji as UTF-8. Use `asciiBytes` for guaranteed-ASCII command names, keys, paths, and empty byte fields. Passing non-ASCII literal/template text to `asciiBytes` is an NS1064 build error.
## Handling tray actions
When a user clicks a tray menu item, the runtime dispatches a `CommandEvent` with source `.tray` and the native `tray_item_id`. Prefer command-backed items when the item represents a known app action:
When a user clicks a tray menu item, the runtime dispatches a `CommandEvent` with source `.tray`, the native `status_item_id`, and that menu's `tray_item_id`. Row ids only need to be unique within their own menu. Prefer command-backed items when the item represents a known app action:
```zig
try runtime.createTray(.{
@@ -146,9 +232,9 @@ app.* = PreviewApp.init(allocator, .{}, .{
macOS (`NSStatusItem`) is the proven host; platforms without a status-bar service log a warning and continue. See `examples/canvas-preview` for the live composition.
## Model-driven title and menu
## Model-driven title and menu in Zig-core apps
For a live menu-bar extra — an open-count badge in the title, the latest items in the dropdown — add `UiApp.Options.status_item_fn`. It is consulted on install and after every rebuild, and the runtime re-applies only what actually changed: a title-only change retitles the live status button (no flicker, no menu rebuild), a menu change updates the dropdown. The static `status_item` still provides the icon and tooltip.
For a live menu-bar extra — an open-count badge in the title, the latest items in the dropdown — add `UiApp.Options.status_item_fn`. It is consulted on install and after every rebuild, and the runtime re-applies only what actually changed: shell, presentation, and menu changes patch independently without flicker or native-item recreation. The static `status_item` provides defaults for icon, tooltip, activation, alternate-activation, and open commands; the callback may update those fields live too.
```zig
fn statusItem(model: *const Model, scratch: *App.StatusItemScratch) App.StatusItemState {
@@ -156,43 +242,63 @@ fn statusItem(model: *const Model, scratch: *App.StatusItemScratch) App.StatusIt
scratch.items[0] = .{ .id = 1, .label = "Refresh", .command = "app.refresh" };
scratch.items[1] = .{ .separator = true };
scratch.items[2] = .{ .id = 10, .label = model.latest_title, .command = "issue.select.latest" };
return .{ .title = title, .items = scratch.items[0..3] };
return .{
.presentation = .{ .title = title, .width = 62, .monospaced = true },
.items = scratch.items[0..3],
};
}
// options: .status_item_fn = statusItem,
```
Selections dispatch each item's `command` through `on_command` with source `.tray`, the same shape as window menus. Platforms without a tray-title seam keep the menu updates and log the missing title support once.
For multiple Zig-core items, use `UiApp.Options.status_items_fn`, returning up to eight `App.StatusItemDescriptor` values from `App.StatusItemsScratch`. Descriptor presence creates/removes by `id`; `visible` hides without removing; shell, presentation, and menu hashes reconcile independently. It is mutually exclusive with the singular `status_item` / `status_item_fn` pair.
## The menu-bar app lifecycle
The tray-player pattern (a Spotify-shaped app that lives in the menu bar) is two declarations and two verbs:
The default TypeScript + Native markup recipe combines one app policy, two window policies, and two commands:
1. The window declares `close_policy = "hide"` in app.zon, so the red close button hides it instead of quitting — the app keeps running behind its status item, and on macOS clicking the Dock icon re-shows the hidden window on its own. On Windows this requires the `"tray"` capability (the status item is the only way back to a hidden window there — the build refuses `"hide"` without it, and a tray that fails to install downgrades the first hide-close to a real close with a loud log). The default (`"quit"`) keeps the classic behavior; see [Windows](/docs/windows#close-policy).
2. The tray rows map to the window verbs in `update`: "Open" returns `fx.showWindow(label)` (un-hide + activate; it also restores a minimized window) and "Quit" returns `fx.quitApp()` — the real graceful terminate, riding the same shutdown path a last-window close takes.
1. Declare the `"tray"` capability and set top-level `dock_visible = false`. macOS selects the Accessory activation policy before creating the startup window, so no Dock tile or cmd+Tab entry flashes. The tray requirement is validated because it becomes the app's route back to hidden windows.
2. Set `initially_hidden = true` on the startup window when the app should launch behind the status item. `dock_visible = false` alone removes desktop presence; it does not suppress the window.
3. Set `close_policy = "hide"`, so the red close button hides the window instead of quitting. The default `"quit"` keeps classic windowed-app behavior; see [Windows](/docs/windows#close-policy).
4. Map tray rows to `Cmd.showWindow("main")` and `Cmd.quitApp()`. Show unhides, orders front, and activates; Quit follows the real graceful shutdown path.
```zig
pub const status_items = [_]native_sdk.TrayMenuItem{
.{ .id = 1, .label = "Open Player", .command = "app.open" },
.{ .separator = true },
.{ .id = 2, .label = "Quit", .command = "app.quit" },
};
```zig:app.zon
.capabilities = .{ "native_views", "gpu_surfaces", "tray" },
.dock_visible = false,
.shell = .{
.windows = .{
.{
.label = "main",
.initially_hidden = true,
.close_policy = "hide",
// views...
},
},
},
```
pub fn command(name: []const u8) ?Msg {
if (std.mem.eql(u8, name, "app.open")) return .open_player;
if (std.mem.eql(u8, name, "app.quit")) return .quit;
return null;
```ts:src/core.ts
import { Cmd } from "@native-sdk/core";
export function commandMsg(name: string): Msg | null {
if (name === "app.open") return { kind: "open_player" };
if (name === "app.quit") return { kind: "quit" };
return null;
}
pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
switch (msg) {
.open_player => fx.showWindow("main"),
.quit => fx.quitApp(),
// ...
}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "open_player":
return [model, Cmd.showWindow("main")];
case "quit":
return [model, Cmd.quitApp()];
// other arms...
}
}
```
In the TypeScript tier the same verbs are `Cmd.showWindow("main")` and `Cmd.quitApp()`. `examples/menu-bar` is the whole loop, tested end to end.
`Cmd.setDockPresence(true)` can later promote the same running Accessory app to Regular; passing `false` demotes it again. `examples/menu-bar` is the complete zero-Zig loop with `statusItem(model)` supplying the live title and rows.
Linux is the honest exception: the toolkit has no status item there yet, so nothing could bring a hidden window back — `close_policy = "hide"` is refused at build/create time with a teaching, and the platform-support matrix states it plainly.
+37 -7
View File
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
An app core is pure TypeScript: no npm packages run inside it, because no JS engine ships in the binary. That line is drawn on purpose, and it buys the properties the rest of the toolkit stands on — byte-identical record→replay, headless testing of the whole app, automation over real state, and native dispatch speed with zero allocation at runtime. The language inside the core is complete ([TypeScript Cores](/docs/typescript) covers exactly what that means); the ecosystem lives at the edges, and every edge has a first-class pattern.
The question behind "can I use npm?" is almost always one of these four:
The question behind "can I use npm?" is almost always one of these five:
<table>
<thead>
@@ -15,6 +15,11 @@ The question behind "can I use npm?" is almost always one of these four:
</tr>
</thead>
<tbody>
<tr>
<td>Filesystem, JSON/regex parsing, transforms, or imperative work in ordinary TypeScript</td>
<td>A compiled module under <code>src/services/</code></td>
<td>A native service-host process, reached through <code>@native-sdk/services</code> commands</td>
</tr>
<tr>
<td>An HTTP API — including AI/LLM endpoints</td>
<td><code>Cmd.fetch</code> with routed results</td>
@@ -38,9 +43,15 @@ The question behind "can I use npm?" is almost always one of these four:
</tbody>
</table>
## Compiled TypeScript services
Put ordinary static-tier TypeScript under `src/services/` when the work needs Node built-ins, regexes, JSON, `Map`/`Set`, `Date`, classes, or ambient process authority. Each directly exported, non-default named synchronous function becomes an operation named `<module-basename>.<export>`. Its request and result may be shared, contract-encodable records; the core calls the generated constructor from `@native-sdk/services`, so success and failure still arrive as Msgs and record/replay remains offline.
This is compiled native code with no JavaScript engine. `native vendor . package@X.Y.Z` resolves an exact package graph once, with lifecycle scripts disabled, into checked-in `src/services/vendor/` sources and hash facts in app.zon. Builds are offline: every byte is verified and scriptc receives only the explicit `--npm-static` package list—never automatic or dynamic fallback. `native check` preserves scriptc's coverage note and refuses anything below 100% static coverage. The checked-in five-package calibration passed three small source-shipping utilities and refused two (`nanoid` and `micromark`), so package support is intentionally selective. Services run in a lazily started child process by default, with an explicit in-process opt-in where the compiler can localize the target archive; [TypeScript Services](/docs/typescript/services) covers the exact platform/architecture matrix, typed calls, streaming, cancellation, authority, and crash recovery. [`examples/service-feed-reader`](https://github.com/vercel-labs/native/tree/main/examples/service-feed-reader) is the reference: `Cmd.fetch` downloads a feed, the service parses the bytes into typed records through the generated client, and the recorded loop replays without the service or the network.
## Calling APIs, AI endpoints included
Most packages people reach for first — API clients, AI SDKs — are HTTP wrappers. The HTTP is already in the toolkit: `Cmd.fetch` performs a buffered exchange on the effect engine and routes the result back as an ordinary Msg carrying `{ status, body }`. The request is data, the response is a message, and a recorded session replays the whole conversation with zero network — which is not something an SDK dependency can offer. A complete client for an OpenAI-compatible chat endpoint:
Most packages people reach for first — API clients, AI SDKs — are HTTP wrappers. The HTTP is already in the toolkit: `Cmd.fetch` can perform a buffered exchange and route `{ status, body }`, or line-stream an SSE/NDJSON response through repeated Msgs. The request is data, every response event is a message, and a recorded session replays the whole conversation with zero network — which is not something an SDK dependency can offer. A complete buffered client for an OpenAI-compatible chat endpoint:
<CodeToggle>
@@ -83,7 +94,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
case "answered":
// The status is the real HTTP status - a 404 is a delivered
// response. Parse the body in pure TypeScript over bytes; the
// ai-chat-ts example ships the complete JSON walk.
// The Chatbot example ships the complete JSON walk.
return { ...model, waiting: false, answer: msg.body };
case "ask_failed":
// The transport reason ("timed_out", "connect_failed", ...) -
@@ -108,7 +119,25 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
</CodeToggle>
The flagship version of this pattern is [`examples/ai-chat-ts`](https://github.com/vercel-labs/native/tree/main/examples/ai-chat-ts): a chat client for an OpenAI-compatible endpoint — conversation history in the Model, request encoding and response parsing as plain subset TypeScript over bytes, endpoint and credentials through the env channel with the key riding a runtime-built `Authorization: Bearer` header (header names are compile-time; header values may be runtime bytes), honest sending/failed/unconfigured states — with an end-to-end suite that pins the exact request bytes and replays a recorded conversation with no network in the room and none of the launch variables set. One v1 boundary, stated plainly: responses are buffered, not token-streamed (the engine underneath already streams line-framed bodies on the Zig channel; the TS Cmd surface for it is roadmap).
For token-by-token UI, request the endpoint's streaming mode and add a `line` route:
```ts
Cmd.fetch(
{
url: endpoint,
method: "POST",
headers: { accept: "text/event-stream", authorization: bearerToken },
body: requestBody,
timeoutMs: 120000,
maxLineBytes: 65536,
},
{ key: "chat", line: "chat_event", ok: "chat_done", err: "chat_failed" },
)
```
`chat_event` carries one `Uint8Array` field for each complete SSE/NDJSON line; parse its `data:` payload and append the delta to the assistant message in the Model. `chat_done` carries one number field with the terminal HTTP status. Cancellation and transport failures reach `chat_failed` as reason bytes, including `cancelled`, so a partially displayed answer never ends silently.
The flagship [`examples/chatbot`](https://github.com/vercel-labs/native/tree/main/examples/chatbot) uses that streaming shape against Vercel AI Gateway: the Gateway URL and `openai/gpt-5.6-luna` default are fixed, a dropdown inside the prompt group lists the Luna, Terra, and Sol variants in that order, `AI_GATEWAY_API_KEY` and an optional initial `NATIVE_SDK_CHAT_MODEL` override arrive through the env channel, and every `choices[0].delta.content` extends the visible pending assistant reply before `[DONE]` and the terminal status commit it to history. Its end-to-end suite pins the request, observes partial UI updates, and replays every stream line without network or launch variables.
## Full npm ecosystem UIs
@@ -149,7 +178,8 @@ import { parseCsvRow } from "./csv.ts"; // vendored under src
import { containsIgnoreCase } from "@native-sdk/core/text"; // the SDK library channel
```
- **Vendor it under `src/`.** Subset-clean TypeScript compiles into the core like your own modules ([splitting a core into modules](/docs/typescript#splitting-a-core-into-modules)); the subset checker tells you immediately — by rule ID, with the rewrite — whether a vendored file fits. Code that leans on classes, exceptions, or regexes generally wants rewriting rather than vendoring, and the rewrite is usually smaller than the dependency.
- **`@native-sdk/core/*` is the curated library channel**: SDK modules written in the same subset, compiled into your core when imported and absent when not. Today that is `@native-sdk/core/text` — the byte-splice text engine (caret, selection, IME, case-insensitive search) — and `@native-sdk/core/events` — the canonical event record types markup and the wiring channels match. The channel grows with the toolkit; JSON encoding/parsing over bytes, today demonstrated in `examples/ai-chat-ts/src/api.ts`, is the kind of module it exists to absorb.
- **Vendor subset-clean code under `src/` outside `src/services/`.** It compiles into the core like your own modules ([splitting a core into modules](/docs/typescript#splitting-a-core-into-modules)); the subset checker tells you immediately — by rule ID, with the rewrite — whether it fits.
- **Vendor ordinary static-tier code or exact npm packages under `src/services/`.** Classes, regexes, JSON, `Map`/`Set`, `Date`, Node built-ins, and imperative transforms stay in TypeScript and compile into the service host; run `native vendor . package@X.Y.Z` for a package, then reach its typed exported operations through `@native-sdk/services`.
- **`@native-sdk/core/*` is the curated library channel**: SDK modules written in the same subset, compiled into your core when imported and absent when not. Today that is `@native-sdk/core/text` — the byte-splice text engine (caret, selection, IME, case-insensitive search) — and `@native-sdk/core/events` — the canonical event record types markup and the wiring channels match. The channel grows with the toolkit; JSON encoding/parsing over bytes, today demonstrated in `examples/chatbot/src/api.ts`, is the kind of module it exists to absorb.
One thing deliberately does not exist: a package manager for cores. A core's import graph is exactly the files under `src/` plus the SDK modules — the whole program is readable, the build is hermetic, and nothing arrives at build time that you have not checked in.
One thing deliberately does not exist: a package manager for cores. A core's import graph is exactly its class under `src/` plus the SDK modules. Services stay hermetic: local and npm sources are checked in under `src/services/`, app.zon pins each npm name/version/tree hash, and build performs no install or network step. Nothing arrives at build time that you have not checked in.
+161 -60
View File
@@ -2,9 +2,9 @@ import { CodeToggle } from "@/components/code-toggle";
# TypeScript Cores
An app core is the logic tier of a Native SDK app: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. By default you write it as one TypeScript module — `src/core.ts` — the `@native-sdk/core` frontend checks it, and the external core compiler builds it to native code at build time. No JS engine ships in the binary: the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason.
An app core is a Native SDK app's deterministic logic: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. By default you write it as one TypeScript module — `src/core.ts` — the `@native-sdk/core` frontend checks it, and the external core compiler builds it to native code at build time. No JS engine ships in the binary: the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason.
This is the two-tier shape of the toolkit: Zig is how everything works — the engine, the runtime, every widget — and TypeScript plus [Native markup](/docs/native-ui) are how applications are authored. A whole app is three files and zero Zig: `src/core.ts`, `src/app.native`, and `app.zon`. Writing the core in Zig instead ([App Model](/docs/app-model)) is first-class by choice — same loop, same runtime — and extending the toolkit itself (custom widgets, host services, render passes) is always Zig.
This is the two-tier shape of the toolkit: Zig is how everything works — the engine, the runtime, every widget — and TypeScript plus [Native markup](/docs/native-ui) are how applications are authored. A whole app starts as three files and zero Zig: `src/core.ts`, `src/app.native`, and `app.zon`. When ordinary TypeScript work needs filesystem access, JSON, regexes, `Map`, `Date`, classes, or child processes, add modules under `src/services/`; they compile to native code too and answer the core through the same effect→Msg boundary as every other external action. Writing the core in Zig instead ([App Model](/docs/app-model)) is first-class by choice — same loop, same runtime — and extending the toolkit itself (custom widgets and render passes) is always Zig.
The same `core.ts` is executable TypeScript: it typechecks with stock tsc and runs unmodified under node, which is what makes the fastest dev loop possible:
@@ -51,7 +51,7 @@ export function update(model: Model, msg: Msg): Model {
A complete core in the idiom — readonly interfaces, a tagged Msg, spread updates, map/filter, bytes for text, derived exports:
```ts:src/core.ts
import { asciiBytes } from "@native-sdk/core";
import { utf8Bytes } from "@native-sdk/core";
export type Bytes = Uint8Array;
export type Filter = "all" | "active" | "done";
@@ -77,7 +77,7 @@ export type Msg =
export function initialModel(): Model {
return {
tasks: [{ id: 1, title: asciiBytes("Ship the core"), done: false }],
tasks: [{ id: 1, title: utf8Bytes("Ship the core"), done: false }],
nextId: 2,
filter: "all",
draft: new Uint8Array(0),
@@ -129,7 +129,12 @@ Both regions have fixed, build-time capacities (1 MiB each by default): the fram
## The subset posture
App cores are written in a closed subset of TypeScript, and the subset means one precise thing: TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Every basic statement, operator, and declaration form compiles: plain interfaces, discriminated unions, `switch` (with `default` arms), every loop shape (`for`, `for...of`, `while`, `do...while`, labels with labeled `break`/`continue`), the full operator and assignment family (`**`, shifts, `+=` through `??=`), const record destructuring, namespace imports, spreads, the array methods (`.map`/`.filter`/`.find`/`.reduce`/`.toSorted`/...), `Math`, template literals — everything with exact JS semantics, pinned so node and native always agree (a machine-checked grammar matrix classifies every production of the language, so nothing is missing by accident). Classes and exceptions compile too: data classes (fields, a constructor, methods, `static` methods and `static readonly` consts, erased `private`/`protected` — `new Task(...)`, `this.count`, `Task.fromRow(...)`, mutation under the same local-ownership rule as arrays) compile to plain structs plus functions, and `throw`/`try`/`catch`/`finally` is deterministic control flow — a thrown kind-tagged subset value unwinds to the nearest catch (several distinct shapes may throw; the checker collects them into the core's thrown union, and `catch (e)` narrows it with plain kind tests, no `as` ceremony), `finally` runs on every path, and an uncaught throw is a defined panic exactly where node would crash. What isn't available is exactly two families: the ecosystem the binary cannot carry (npm packages, regexes, `JSON`, Promises, `eval` — no JS engine ships) and constructs that would break the core's guarantees (class inheritance, `async`/`await` — asynchrony is command data, `Map`/`Set`, module-level `let`, `Date.now()`/`Math.random()` inside `update`, runtime type tests, text as indexable strings — a core's text is bytes). Each has an idiomatic replacement the checker teaches by ID (NS1001NS1059) — kind-tagged error shapes narrowed in the catch, time and randomness arrive as message payloads, keyed data is an id-keyed array. Immutability is a rule about SHARED data, not a style: mutation is legal on locally-owned arrays — a scratch array your function creates (a literal or a `.slice()` copy) takes `push`/`pop`/`splice`/in-place `sort`, the `xs[xs.length] = v` append, and the rest with exact JS semantics until the value escapes; a `let` reassigned only from fresh copies stays owned, passing into a `readonly T[]` reader parameter borrows instead of escaping, and the checker teaches only at the real boundaries. Generics are ordinary TypeScript too: a module-level generic function, interface, or type alias monomorphizes per call site from tsc's own resolved type arguments — one native function per instantiation. These rules scope to app cores, the logic tier; they say nothing about the TypeScript you write anywhere else. Where the npm ecosystem fits — calling APIs (AI endpoints included), embedding npm-heavy web UIs, running node as a worker, vendoring utilities — has its own page: [Where Packages Go](/docs/typescript/packages).
App cores are written in a closed subset of TypeScript, and the subset means one precise thing: TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Every basic statement, operator, and declaration form compiles: plain interfaces, discriminated unions, `switch` (with `default` arms), every loop shape (`for`, `for...of`, `while`, `do...while`, labels with labeled `break`/`continue`), the full operator and assignment family (`**`, shifts, `+=` through `??=`), const record destructuring, namespace imports, spreads, the array methods (`.map`/`.filter`/`.find`/`.reduce`/`.toSorted`/...), `Math`, template literals — everything with exact JS semantics, pinned so node and native always agree (a machine-checked grammar matrix classifies every production of the language, so nothing is missing by accident). Classes and exceptions compile too: data classes (fields, a constructor, methods, `static` methods and `static readonly` consts, erased `private`/`protected` — `new Task(...)`, `this.count`, `Task.fromRow(...)`, mutation under the same local-ownership rule as arrays) compile to plain structs plus functions, and `throw`/`try`/`catch`/`finally` is deterministic control flow — a thrown kind-tagged subset value unwinds to the nearest catch (several distinct shapes may throw; the checker collects them into the core's thrown union, and `catch (e)` narrows it with plain kind tests, no `as` ceremony), `finally` runs on every path, and an uncaught throw is a defined panic exactly where node would crash. What isn't available is exactly two families: the ecosystem the core cannot carry (npm packages, regexes, `JSON`, Promises, `eval`) and constructs that would break the core's guarantees (class inheritance, `async`/`await` — asynchrony is command data, `Map`/`Set`, module-level `let`, `Date.now()`/`Math.random()` inside `update`, runtime type tests, text as indexable strings — a core's text is bytes). Each has an idiomatic replacement the checker teaches by ID — kind-tagged error shapes narrowed in the catch, time and randomness arrive as message payloads, keyed data is an id-keyed array, and ordinary static-tier work moves behind a `src/services/` request. Immutability is a rule about SHARED data, not a style: mutation is legal on locally-owned arrays — a scratch array your function creates (a literal or a `.slice()` copy) takes `push`/`pop`/`splice`/in-place `sort`, the `xs[xs.length] = v` append, and the rest with exact JS semantics until the value escapes; a `let` reassigned only from fresh copies stays owned, passing into a `readonly T[]` reader parameter borrows instead of escaping, and the checker teaches only at the real boundaries. Generics are ordinary TypeScript too: a module-level generic function, interface, or type alias monomorphizes per call site from tsc's own resolved type arguments — one native function per instantiation. These rules scope to the core class. Files under `src/services/` skip NS1001NS1064 and are judged by the same pinned compiler's ordinary static tier instead; the class boundary rules NS1065NS1067 keep the deterministic core and ambient-authority service separate. Where the ecosystem fits has its own page: [Where Packages Go](/docs/typescript/packages).
Every rule in the catalogue carries a class. A `guarantee` rule protects a core invariant — determinism and replay, fixed shapes, immutability of shared data, the one text representation — and is permanent. A `deferred` rule bans nothing those invariants require; the capability waits on a deliberate easing decision, and its diagnostic says so.
- `guarantee` — permanent: NS1001 (shared data is immutable), NS1002 (updates are synchronous), NS1005 (update is deterministic), NS1010 (module state lives in the Model), the byte-text rules (NS1004, NS1018, NS1024, NS1060), and every other rule not listed as deferred.
- `deferred` — awaiting an easing decision: NS1011 (`Map`/`Set`), NS1019 (fixed arity: parameter defaults, rest, `arguments`, call spreads), NS1040 (regular expressions), NS1042 (generators), NS1044 (`BigInt`/`Symbol`).
<CodeToggle>
@@ -155,16 +160,17 @@ pub fn pick__f64(xs: []const f64, i: i64) f64 {
</CodeToggle>
One rule deserves calling out early: **text is bytes**. Dynamic, user-visible text lives in the Model as `Uint8Array` — `string` is for literals, string-literal-union tags, and `===` comparisons. Turn literals and templates into bytes with the `asciiBytes` intrinsic:
One rule deserves calling out early: **text is bytes**. Dynamic, user-visible text lives in the Model as `Uint8Array` — `string` is for literals, string-literal-union tags, and `===` comparisons. Turn display literals and templates into UTF-8 with `utf8Bytes`; use `asciiBytes` only when ASCII is part of the value's contract, such as a command name, key, or protocol token:
```ts
import { asciiBytes } from "@native-sdk/core";
import { asciiBytes, utf8Bytes } from "@native-sdk/core";
const label = asciiBytes(`${done} of ${total} done`); // per-dispatch bytes
const seed = asciiBytes("Stretch"); // rodata, free to commit
const label = utf8Bytes(`${done} of ${total} done`); // per-dispatch UTF-8
const seed = utf8Bytes("Café…"); // UTF-8 rodata
const command = asciiBytes("app.refresh"); // guaranteed ASCII
```
The compiler folds every `asciiBytes` call at compile time; under node the same import runs as a plain function with the same result. Observing a `string`'s code units (`.length`, `s[i]`) is a taught error because UTF-16 and UTF-8 would disagree, and `+` concatenation is taught away because runtime string building needs a JS string heap the binary does not carry.
The compiler folds both byte intrinsics at compile time; under node the same imports run as plain functions with the same result. `asciiBytes` fails with NS1064 when a literal/template contains non-ASCII and throws `RangeError` if called directly with such text under node. `utf8Bytes` encodes Unicode exactly like `TextEncoder`, including U+FFFD for lone surrogates. Observing a `string`'s code units (`.length`, `s[i]`) is a taught error because UTF-16 and UTF-8 would disagree, and `+` concatenation is taught away because runtime string building needs a JS string heap the binary does not carry.
Bytes still read like text: the everyday string methods work directly on `Uint8Array` values, with **byte-honest semantics** — every length, offset, and index is a BYTE length/offset (never a character count: `é` measures 2), search is byte-wise, and case mapping is Unicode simple case mapping (code point to code point from the Unicode tables, locale-free, no special casing — `ß` stays `ß`; invalid UTF-8 passes through unchanged). The compiled core and node run the same methods from the same generated tables, so both produce identical bytes by construction.
@@ -278,14 +284,42 @@ The runtime interprets the command after the model commits and dispatches any re
<td><code>Cmd.writeFile(path, bytes, &#123; key?, ok, err &#125;)</code></td>
<td>Write a whole file (parents created, replaced whole); <code>ok</code> carries no payload — a successful write has nothing to report</td>
</tr>
<tr>
<td><code>Cmd.appendFile</code> / <code>Cmd.statFile</code> / <code>Cmd.deleteFile</code></td>
<td>Append one bounded payload, inspect <code>&#123; exists, size, mtimeMs &#125;</code>, or delete one file with explicit <code>not_found</code> handling</td>
</tr>
<tr>
<td><code>Cmd.readFileStream</code> / <code>Cmd.writeFileStream</code> + <code>writeFileChunk</code>/<code>writeFileClose</code></td>
<td>Read 256-KiB chunks without a total-size cliff, or build an atomic export one acknowledged chunk at a time — see <a href="/docs/files">Files &amp; Streaming</a></td>
</tr>
<tr>
<td><code>Cmd.fetch(spec, &#123; key?, ok, err &#125;)</code></td>
<td>A buffered HTTP(S) exchange; <code>ok</code> carries <code>&#123; status, body &#125;</code> (a 404 is still <code>ok</code> — a delivered response), <code>err</code> the transport reason</td>
</tr>
<tr>
<td><code>Cmd.fetch(spec, &#123; key?, line, ok, err &#125;)</code></td>
<td>A line-streamed HTTP(S) exchange for SSE/NDJSON; each <code>line</code> carries bytes as it arrives, then <code>ok</code> carries the terminal HTTP status or <code>err</code> the transport reason</td>
</tr>
<tr>
<td><code>Cmd.clipboardWrite(bytes)</code> / <code>Cmd.clipboardRead(&#123; key?, ok, err &#125;)</code></td>
<td>System clipboard: write is fire-and-forget, read routes the text bytes back</td>
</tr>
<tr>
<td><code>Cmd.showNotification(&#123; id?, title, subtitle?, body?, actionLabel?, actionCommand? &#125;)</code></td>
<td>Show or replace a desktop notification, fire-and-forget; paired action fields dispatch through the ordinary app-command path while the process is running</td>
</tr>
<tr>
<td><code>Cmd.openExternalUrl(url)</code> / <code>Cmd.revealPath(path)</code></td>
<td>Open an allowed HTTP(S) URL in the system browser or reveal a path in Finder/Files/Explorer; both are fire-and-forget and fail closed</td>
</tr>
<tr>
<td><code>Cmd.credentials.set(...)</code> / <code>Cmd.credentials.get(...)</code> / <code>Cmd.credentials.delete(...)</code></td>
<td>App-scoped access to the OS credential store; get returns secret bytes, missing items route <code>miss</code>, and the manifest must declare the <code>credentials</code> capability and permission</td>
</tr>
<tr>
<td><code>Cmd.formatLocalTime(timestampMs, style, route)</code></td>
<td>Format an epoch timestamp as localized <code>date</code>, <code>time</code>, or <code>datetime</code> text in the host's current time zone</td>
</tr>
<tr>
<td><code>Cmd.spawn(argv, &#123; key?, stdin?, line?, exit, err &#125;)</code></td>
<td>Run a subprocess, streaming stdout line by line; <code>collect: true</code> buffers whole stdout into the <code>exit</code> arm instead</td>
@@ -295,8 +329,16 @@ The runtime interprets the command after the model commits and dispatches any re
<td>The audio player: one event stream (<code>loaded</code>, <code>position</code>, <code>completed</code>, <code>failed</code>, <code>spectrum</code>, ...) until <code>audioStop</code> closes it</td>
</tr>
<tr>
<td><code>Cmd.showWindow(label)</code> / <code>Cmd.quitApp()</code></td>
<td>The menu-bar lifecycle verbs: un-hide + activate the labeled window (the tray "Open" consequence, the counterpart to <code>close_policy = "hide"</code>), and the real graceful terminate</td>
<td><code>Cmd.showWindow(label)</code> / <code>Cmd.hideWindow(label)</code> / <code>Cmd.quitApp()</code></td>
<td>The menu-bar lifecycle verbs: show or retain-but-hide the labeled window, and gracefully terminate the app</td>
</tr>
<tr>
<td><code>Cmd.setDockPresence(visible)</code></td>
<td>Switch macOS between regular Dock/app-switcher presence and accessory/headless behavior; unsupported hosts ignore it</td>
</tr>
<tr>
<td><code>Cmd.launchAtLoginStatus(route)</code> / <code>Cmd.setLaunchAtLogin(enabled, route)</code></td>
<td>Query or change the installed app bundle's <code>SMAppService</code> registration; the ok bytes name <code>enabled</code>, <code>disabled</code>, <code>requires_approval</code>, or <code>not_found</code></td>
</tr>
<tr>
<td><code>Cmd.imageLoad(id, source, &#123; event &#125;)</code> + <code>imageCancel(id)</code>/<code>imageUnregister(id)</code></td>
@@ -306,6 +348,22 @@ The runtime interprets the command after the model commits and dispatches any re
<td><code>Cmd.channelOpen(key, &#123; event &#125;)</code> / <code>channelClose(key)</code></td>
<td>Open an external-source channel under an app-chosen numeric key: the native side holds the posting handle and feeds bytes from its own threads, and every post arrives through the one <code>event</code> arm with the back-pressure counters aboard. <code>channelClose</code> flushes staged posts, dispatches exactly one <code>closed</code> event with the final drop totals, and frees the key</td>
</tr>
<tr>
<td><code>Cmd.audioCaptureStart(key, spec, &#123; event &#125;)</code> / <code>audioCaptureStop(key)</code></td>
<td>Capture <code>microphone</code> or <code>system</code> audio as bounded, timestamped, interleaved signed-16 LE PCM chunks. The stream reports <code>started</code>, <code>data</code>, <code>failed</code>, <code>stopped</code>, and <code>rejected</code>, with observable drop counters</td>
</tr>
<tr>
<td><code>Cmd.persist()</code></td>
<td>Snapshot the just-committed Model through the engine-owned, capability-gated atomic store; restore arrives through the manifest's configured boot Msg route — see <a href="/docs/persistence">Model Persistence</a></td>
</tr>
<tr>
<td><code>Cmd.store.set/get/delete/scan/setMany</code></td>
<td>Persist independent byte records in the engine-owned, capability-gated record store; every result returns through the declared Msg route — see <a href="/docs/record-store">Record Store</a></td>
</tr>
<tr>
<td><code>Cmd.db.query(sql, params, route)</code> / <code>Cmd.db.exec(statements, route)</code></td>
<td>Run read-only relational queries as bounded row pages or commit a statement list atomically through the engine-owned SQLite database — see <a href="/docs/sqlite">Relational SQLite</a></td>
</tr>
<tr>
<td><code>Cmd.host(name, ...args)</code> / <code>Cmd.request(name, payload, &#123; key?, ok, err &#125;)</code></td>
<td>App-defined host commands by literal name: fire-and-forget, or routed with exactly one result Msg back</td>
@@ -317,49 +375,13 @@ The runtime interprets the command after the model commits and dispatches any re
</tbody>
</table>
Result arms are ordinary Msg arms with the shape the effect produces — one `Uint8Array` field for host results and errors, one number field for timer fires, no fields for `writeFile`'s ok, one number plus one `Uint8Array` field for `fetch`'s — and tsc checks the shapes for you. Keys carry ONE in-flight discipline: a keyed effect — `Cmd.request`, the named engine ops, `Cmd.delay` — whose key is already in flight replaces the old one (the superseded result is dropped, no message; the debounce shape), and `Cmd.cancel` drops it silently. The one exception is a live `Cmd.spawn` key, which rejects the duplicate (`err` gets `rejected`) — a running subprocess is never killed implicitly; cancel it first, and that cancel is loud (`err` gets `cancelled`) because killing a process is an observable event. Every `err` arm receives a machine-readable reason, so failure is never silence. Persistence today is `Cmd.writeFile` + a boot-time `Cmd.readFile` — the pattern every real app uses.
Result arms are ordinary Msg arms with the shape the effect produces — one `Uint8Array` field for raw host results and errors, a generated service result record, one number field for timer fires and stream totals, no fields for write acknowledgments, and one number plus one `Uint8Array` field for a buffered fetch's result — and tsc checks the shapes for you. Buffered engine effects and streamed file reads replace a live same-key predecessor and cancel silently. Live `Cmd.spawn`, streaming-fetch, streaming-service, and streamed write-sink keys reject duplicates so two producers cannot splice; cancelling those is loud through `err: cancelled`. A streaming fetch whose line is cut or dropped also ends with `err: truncated`, never a misleading successful status. Every routed `err` arm receives a machine-readable reason.
The debounced-autosave shape, in full — edits re-arm a one-shot; one write lands after the pause:
Platform state stays on the same effect boundary. `Cmd.openExternalUrl(url)` enforces [`security.navigation.external_links`](/docs/security#external-links) before entering the browser; `Cmd.revealPath(path)` uses the desktop file manager. Credential operations take byte `service` and `account` identifiers plus the standard `{ key?, ok, err }` route: set/delete return empty bytes on `ok`, get returns the secret, and a missing item routes `not_found`. `Cmd.formatLocalTime(timestampMs, "date" | "time" | "datetime", route)` returns localized UTF-8 bytes using the current host locale and time zone. That formatting is deliberately a Cmd—not a pure helper—so session recording captures the observed text and replay never re-reads ambient locale or timezone state.
```ts:src/core.ts
import { Cmd, asciiBytes } from "@native-sdk/core";
Durable in-memory state uses `Cmd.persist()`: declare the `persist` capability, configure the boot routes and schema version, then return the command beside the committed model. The engine owns canonical serialization, trailing-edge coalescing, atomic app-data placement, backup recovery, migration, and journal/replay. See [Model Persistence](/docs/persistence) for the complete setup. Raw file commands remain for user-visible files, exports, and blobs; [Files & Streaming](/docs/files) covers their bounds, atomic sink protocol, replay, and `filesystem` permission gate.
export interface Model {
readonly draft: Uint8Array;
readonly dirty: boolean;
}
export type Msg =
| { readonly kind: "draft_edit"; readonly text: Uint8Array }
| { readonly kind: "save_now"; readonly at: number }
| { readonly kind: "saved" }
| { readonly kind: "save_failed"; readonly reason: Uint8Array };
export const viewUnbound = ["save_now", "saved", "save_failed", "dirty"] as const;
export function initialModel(): Model {
return { draft: new Uint8Array(0), dirty: false };
}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "draft_edit":
return [
{ ...model, draft: msg.text, dirty: true },
Cmd.delay("autosave", 800, "save_now"),
];
case "save_now":
return [
model,
Cmd.writeFile(asciiBytes("draft.bin"), model.draft, { key: "save", ok: "saved", err: "save_failed" }),
];
case "saved":
return { ...model, dirty: false };
case "save_failed":
return model;
}
}
```
Independent byte records use `Cmd.store`: declare the `store` capability, then route set/get/delete/scan/setMany results back to Msg arms. The engine owns the app-data path, SQLite schema, atomic batches, pagination, and replay boundary. See [Record Store](/docs/record-store).
External sources — sockets, file watchers, native worker threads — reach `update` through a channel. `Cmd.channelOpen(key, { event })` opens a long-lived stream under an app-chosen numeric key, and every event dispatches the one `event` arm as a five-field record; `state` must be a named string-literal-union alias carrying exactly the three members — a narrower union would silently drop states the host emits, so the build refuses it. Posting is not a TS verb: compiled cores are single-threaded by design, so the posting handle lives on the native side (`Effects.channelHandle(key)`), where embedders and platform-services extensions post bytes from their own threads. Back-pressure is honest — posts the native handle refused count into `droppedPending`/`droppedTotal` on the next delivered event, never silence — and a duplicate open on a live key dispatches `rejected`. `Cmd.channelClose(key)` ends the stream: staged posts flush, exactly one `closed` event carries the final totals, and the key frees.
@@ -411,6 +433,59 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
}
```
Audio input uses the same bounded, wake-driven stream transport without requiring native posting code. `Cmd.audioCaptureStart(key, { source, sampleRate?, channels? }, { event })` captures the microphone or the desktop output mix. The supported canonical rates are 16, 24, and 48 kHz; channels are mono or stereo; the default is 48 kHz mono. Each `data` event carries at most 20 ms of interleaved signed 16-bit little-endian PCM in `pcm`, plus `timestampMs`, `frames`, the delivered format, and drop counters. Microphone and system capture can run concurrently, but only one stream per source is live; starting that source again stops the prior key. `Cmd.audioCaptureStop(key)` quiesces the native callback, drains accepted chunks, then emits one `stopped` terminal. A key remains occupied until that terminal is delivered, so wait for `stopped` before reusing it. Add `"microphone"` and/or `"system_audio"` to `app.zon` permissions so packaged macOS apps receive the required usage descriptions and consent prompts.
```ts:src/core.ts
import { Cmd, type AudioCaptureState, type AudioCaptureSource } from "@native-sdk/core";
export type Msg =
| { readonly kind: "record" }
| { readonly kind: "stop" }
| { readonly kind: "audio_chunk"; readonly key: number; readonly state: AudioCaptureState; readonly source: AudioCaptureSource; readonly sampleRate: number; readonly channels: number; readonly timestampMs: number; readonly frames: number; readonly pcm: Uint8Array; readonly droppedPending: number; readonly droppedTotal: number };
// In update:
// case "record": return [model, Cmd.audioCaptureStart(1, { source: "microphone", sampleRate: 48000, channels: 1 }, { event: "audio_chunk" })];
// case "stop": return [model, Cmd.audioCaptureStop(1)];
```
## Model-derived menu-bar status items
A `src/core.ts` app can own its complete native menu-bar item without custom Zig wiring. Export `statusItem(model): StatusItemState`; the generated launcher installs its icon, tooltip, click/open commands, presentation, and rows from the boot model, then re-derives all of them after committed updates. It patches shell, presentation, and menu independently and never recreates the item just because model state changed.
```ts:src/core.ts
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("Sync status"),
activationCommand: asciiBytes("app.sync"),
alternateActivationCommand: asciiBytes(""),
openCommand: asciiBytes("app.sync"),
presentation: { title: model.syncing ? utf8Bytes("SYNC…") : utf8Bytes("READY"), width: 62, tone: model.failed ? "critical" : "normal", iconOpacity: model.stale ? 0.5 : 1, monospaced: true },
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: 2, label: utf8Bytes("Sync now…"), command: asciiBytes("app.sync"), separator: false, enabled: !model.syncing, detail: asciiBytes(""), role: "command", key: asciiBytes("r"), modifiers: { primary: true, command: false, control: false, option: false, shift: false } },
],
};
}
```
Import the canonical records and unions from `@native-sdk/core/events`. Presentation includes byte `title`, numeric `width`, `normal | warning | critical` tone, `iconOpacity` in 0…1, and `monospaced`. Rows include id/label/command/separator/enabled plus secondary `detail`, semantic `role`, key equivalent, and all five modifier booleans. Actionable ids are unique and non-zero, and there are at most 32 rows. `commandMsg(name): Msg | null` maps row selection, status-button activation, Option-activation, and menu-open refresh into the ordinary update loop. See [System Tray](/docs/tray) and the zero-Zig `examples/menu-bar` app for the full hide/Open/Quit lifecycle.
Export `statusItems(model): readonly StatusItemDescriptor[]` when the app needs several independent items. Each descriptor adds stable non-zero `id` identity and a live `visible` flag to the same shell/presentation/menu record. Adding/removing descriptors creates/removes only those ids; icon, title, tooltip, visibility, activation/open commands, and menu changes patch in place. Export either the singular or collection helper, not both. macOS supports up to eight simultaneous items; every item keeps its own 32-row menu.
## Model-declared secondary windows
Export `windows(model): readonly WindowDescriptor[]` to derive the live secondary-window set from model state. Construct entries with `windowDescriptor` from `@native-sdk/core`, import `WindowDescriptor` from `@native-sdk/core/events`, and put each window's markup at `src/windows/<label>.native`. Spell the constructor label as a literal `label: asciiBytes("<label>")`; `native check` and every build reject dynamic labels or a label without that matching root. Window roots can import shared components nested under `src/windows/`; the generated launcher embeds and hot-reloads the complete import closure. Adding/removing descriptors creates/closes only those windows; all open windows rebuild from the same committed model.
`closePolicy` accepts `"quit"` (the default) or `"hide"`. A `"quit"` user close routes `onCloseCommand` through `commandMsg`, where the app maps it to the Msg that clears its open flag. A `"hide"` close retains the same native window and view and dispatches no close command; `Cmd.showWindow(label)` reveals it. Model-declared secondary windows are desktop-only. See `examples/system-monitor-ts`.
`restorePolicy` accepts `"clamp_to_visible_screen"` (the default) or `"center_on_primary"`. Model-declared windows do not restore persisted frames. On macOS, `"center_on_primary"` centers a fresh descriptor with no authored `x`/`y`; Windows and Linux currently keep their native default placement.
`titlebar` accepts `"standard"`, `"hidden_inset"`, `"hidden_inset_tall"`, or `"chromeless"`. Transparent Windows windows require `"chromeless"`; because that removes the system buttons, fully skinned windows must draw working close/minimize controls.
## Subscriptions are Sub data
Recurring effects are declared, not issued: export `subscriptions(model): Sub<Msg>` and return descriptors derived from the current model. After every commit the host reconciles the returned set against its active timers by key — a new key (or a changed interval) arms a timer, a missing key cancels it — so starting, stopping, and re-tuning timers is just returning different data:
@@ -455,7 +530,7 @@ export function subscriptions(model: Model): Sub<Msg> {
}
```
Keep the Sub-vs-stream line straight: a Sub is declarative — derived from the model, started and stopped by reconciliation, never opened or closed by the app. The multi-result streams (`Cmd.spawn`'s lines, `Cmd.audioPlay`'s events, `Cmd.channelOpen`'s posts) are Cmd-initiated — imperative opens with a keyed lifecycle the app drives. If the effect should exist exactly while some model state holds, it wants a Sub; if the app decides when it starts and ends, it is a stream.
Keep the Sub-vs-stream line straight: a Sub is declarative — derived from the model, started and stopped by reconciliation, never opened or closed by the app. The multi-result streams (`Cmd.fetch`'s response lines, `Cmd.spawn`'s stdout lines, `Cmd.audioPlay`'s events, `Cmd.channelOpen`'s posts, and audio capture chunks) are Cmd-initiated — imperative opens with a keyed lifecycle the app drives. If the effect should exist exactly while some model state holds, it wants a Sub; if the app decides when it starts and ends, it is a stream.
## Text input from markup
@@ -463,7 +538,7 @@ A markup text control (`<text-field text="{draft}" on-input="draft_edit" />`) ne
## Splitting a core into modules
A core that outgrows one file splits into modules under `src/`: relative imports spelled with their real filenames (`./parsers.ts` — the same file runs under node, whose loader resolves real files), `src/` as the hard boundary (`../` and npm packages are teaching errors), and no runtime cycles (`import type` back-edges are fine and idiomatic — a helper module typically type-imports `Model` from the entry). Export lists and value re-exports are ordinary module surface: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name — what stays out is `export default`, `export =`, and `export * from` (the core's flat namespace resolves by name, so every export names what it binds). `core.ts` stays the entry module and the app's public face: `update`, `initialModel`, `subscriptions`, the wiring channels, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — `@native-sdk/core/text` is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and `@native-sdk/core/events` is the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchPhase`/`PinchEvent`, `ColorScheme`, the chrome records, `AudioState`/`AudioEvent`) so no core re-types the vocabulary — compiled into your core when imported and absent when not.
A core that outgrows one file splits into modules under `src/` except `src/services/`: relative imports spelled with their real filenames (`./parsers.ts` — the same file runs under node, whose loader resolves real files), `src/` as the hard boundary (`../` and npm packages are teaching errors), and no runtime cycles (`import type` back-edges are fine and idiomatic — a helper module typically type-imports `Model` from the entry). The core may not import service files, even type-only; shared subset-legal shapes live in an ordinary core-class module which a service may import. Export lists and value re-exports are ordinary module surface: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name — what stays out is `export default`, `export =`, and `export * from` (the core's flat namespace resolves by name, so every export names what it binds). `core.ts` stays the entry module and the app's public face: `update`, `initialModel`, `subscriptions`, the wiring channels, `themeState` / `themePack` / `statusItem` / `statusItems` / `windows`, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — `@native-sdk/core/text` is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and `@native-sdk/core/events` is the canonical event and shell vocabulary (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchPhase`/`PinchEvent`, `ColorScheme`, `ThemeState`, the chrome records, `AudioState`/`AudioEvent`, status-item records, and `WindowDescriptor`) so no core re-types it — compiled into your core when imported and absent when not.
<CodeToggle>
@@ -490,26 +565,52 @@ pub fn parseSample(bytes: []const u8) ?Sample { ... }
</CodeToggle>
## TypeScript services
The core is the app's deterministic logic — `Model`, `Msg`, `update`; services do the app's imperative work. A service operation is a directly exported, non-default named synchronous function under `src/services/`, taking zero or one explicitly typed request and declaring a contract-encodable result. Crossing shapes live in an exported, subset-legal module outside `src/services/` so the core and service import one declaration. The operation name is `<module-basename>.<export>`; `native check` projects its complete type table into `services.contract.json`, checks both classes, and generates the typed core client:
```ts:src/core.ts
import { feedsParse } from "@native-sdk/services";
case "parse":
return [
model,
feedsParse({ source: model.source, caseSensitive: false }, {
key: "parse",
ok: "parsed", // one ParseResult field
err: "parse_failed", // one Uint8Array field
}),
];
```
The core never receives a synchronous handle. Its update returns a command, the typed result crosses back as the named Msg arm, and the runtime journals that result like every other effect. Replay parks the request and feeds the recorded result without starting the carrier. The service itself is ordinary static-tier TypeScript — Node built-ins, `fetch`, regexes, JSON, `Map`/`Set`, `Date`, classes — running with the app's privileges on a supervised, lazily started carrier: a sibling child process with a sanitized environment by default, or an explicitly selected in-process worker-thread pool. Writing operations, kind-tagged error throws, streaming and cancellation, exact vendored npm, and the boundary rules NS1065NS1067 have their own chapter: [TypeScript Services](/docs/typescript/services).
## The dev loop
`native dev --core` is the fastest loop for logic work: the core runs under node with a virtual host — dispatch Msgs as JSON lines (`{"kind":"add"}`, `{"$bytes":"…"}` for bytes payloads), advance a virtual clock (`{"advance":1000}`) to fire timers deterministically, and watch the committed model and effect transcript. Effects the virtual host does not perform (files, fetch, spawn) print as `cmd ...` lines — feed their results back yourself as ordinary Msg lines; that is the point, results are plain messages. Pair `--script msgs.ndjson` with `--watch` to replay a scenario on every edit. [Quick Start](/docs/quick-start#the-fastest-loop-the-core-under-node) shows a full transcript.
`native dev --core` is the fastest loop for logic work: the core runs under Node with a virtual host — dispatch Msgs as JSON lines (`{"kind":"add"}`, `{"$bytes":"…"}` for bytes payloads), advance a virtual clock (`{"advance":1000}`) to fire timers deterministically, and watch the committed model and effect transcript. Service requests run in an isolated Node worker through the same generated contract: vendored hashes are verified, request/results use the same codecs and error arms, cooperative cancellation/deadlines interrupt CPU-bound work, and stream chunks use the same channel-event shape. Pair `--script msgs.ndjson` with `--watch` to replay a scenario on every edit. The devhost also consumes `NATIVE_SDK_SESSION_RECORD`/`NATIVE_SDK_SESSION_REPLAY` (the environment set by `native automate record|replay`): it writes the native journal format, and replay starts no service worker. Service-only recordings cross between it and the packaged runtime; packaged recordings containing other effect families use `native automate replay`, and devhost rejects those records explicitly. `native dev` runs compiled services for real beside the native app. [Quick Start](/docs/quick-start#the-fastest-loop-the-core-under-node) shows a full transcript.
`native dev` keeps markup instant — `.native` edits hot-reload into the running window — but a `src/core.ts` edit rebuilds the core through the external core compiler and restarts the app: seconds per rebuild (roughly 3-6s warm), not sub-second. The core loop in the real window is restart-shaped; keep logic iteration under `native dev --core` and rebuild when you want to see it live.
`native check` runs the subset checker (real tsc semantics plus the app-core rules) over `src/core.ts` and its whole import graph (diagnostics carry each module's own path), then validates markup and `app.zon`. Every diagnostic names the rule, the idiomatic rewrite, and the reason — write to them up front and the loop stays fast.
`native check` runs the subset checker (real tsc semantics plus the app-core rules) over the core class, emits and validates the service contract, runs the pinned compiler's coverage verdict over each independent service root, then validates markup and `app.zon`. Every diagnostic names the rule, the idiomatic rewrite, and the reason — write to them up front and the loop stays fast.
## Build targets
Builds compile everything in the app — the core archive, any service executables or in-process archives, and the runner — for one stated target. The default is the build host; `-Dtarget` selects a cross desktop target following the pinned compiler's build matrix: Linux and Windows GNU targets build from any macOS, Linux, or Windows host, and macOS targets build on a macOS host (Apple linking needs the host toolchain's SDK). A Windows MSVC target builds natively on a matching Windows host; cross-Windows builds use the GNU ABI because Zig supplies that target's CRT and system libraries. An explicitly spelled Linux `-gnu` target also states its glibc version — `x86_64-linux-gnu.2.36` or later, or `x86_64-linux-musl` — because the compiled runtime needs glibc 2.36+ (a bare `-gnu` spelling lands on Zig's older default floor and is refused with the same teaching). The executable name and packaging follow the target OS.
Mobile targets compile the same core as a static archive merged into the mobile embed library, which `native dev|package --target ios|android` link into the toolkit hosts exactly as they do for Zig cores. The mobile matrix is aarch64 only: `aarch64-ios` and `aarch64-ios-simulator` build on a macOS host against the selected Apple SDK with an iOS 15.0 floor, and `aarch64-linux-android` builds on any desktop host against an installed NDK (`ANDROID_NDK_ROOT`, or the newest `ndk/<version>` under the SDK) with an API 26 floor. Services on mobile run only on the in-process pool — mobile apps cannot spawn a sibling process — so `service_carrier = "auto"` resolves to the pool there and an explicit `"child"` is refused with a teaching; desktop builds of the same app keep the child carrier under `auto`. The vendored npm lane is unchanged. Model persistence (`persist`), boot images, and URL media caching are not wired on mobile yet.
## Editor support
Editor support is stock tsc — no extension, no plugin. The scaffold ships `package.json` and `tsconfig.json` as the editor-and-versioning surface: the tsconfig mirrors the compiler options the checker itself builds its program with (strict, `moduleResolution: "bundler"`, `verbatimModuleSyntax`, `exactOptionalPropertyTypes`, …), so what your editor flags is what `native check` flags, and `@native-sdk/core` (plus subpaths like `@native-sdk/core/text`) resolves through `node_modules` like any package. Until `@native-sdk/core` is published to npm, the CLI materializes that `node_modules` copy itself — exactly the files the published package will contain — and `native check`/`dev`/`build` keep it fresh against the SDK (`native doctor` reports skew). After the publish, a plain `npm install` writes identical content and takes over. None of it is build truth: builds check and compile against the SDK the CLI ships with and never read `node_modules` — delete it and every `native` verb still works.
Editor support is stock tsc — no extension, no plugin. The scaffold ships `package.json` and `tsconfig.json` as the editor-and-versioning surface: the tsconfig mirrors the compiler options the checker itself builds its program with (strict, `moduleResolution: "bundler"`, `verbatimModuleSyntax`, `exactOptionalPropertyTypes`, …), so what your editor flags is what `native check` flags, and `@native-sdk/core` (plus subpaths like `@native-sdk/core/text`) resolves through `node_modules` like any package. Apps with services also receive an ignored `node_modules/@native-sdk/services` editor package whenever `native check` or `native dev --core` regenerates the typed client; authored `src/` stays clean. Until `@native-sdk/core` is published to npm, the CLI materializes that package copy itself — exactly the files the published artifact will contain — and `native check`/`dev`/`build` keep it fresh against the SDK (`native doctor` reports skew). After the publish, a plain `npm install` writes identical core content and takes over. None of it is build truth: builds check and compile against the SDK the CLI ships with and never read `node_modules` — delete it and every `native` verb still works.
## Outgrowing the subset
The compiled core is a native static archive, not generated source: `native check` checks and leaves nothing behind, and there is no emitted Zig to read or adopt. If an app outgrows the subset, the migration path is porting the core to Zig by hand — the [App Model](/docs/app-model) page covers the Zig wiring, and the wiring the build generates for a TypeScript app doubles as the port's blueprint: same loop, same runtime, so the port is a translation of `update` and friends, not a redesign.
The compiled core is a native static archive, not generated source: `native check` checks and leaves nothing behind, and there is no emitted Zig to read or adopt. If logic needs ambient APIs or ordinary static-tier TypeScript, keep deterministic state transitions in the core and move that work into `src/services/`. Port the core to Zig only when the logic tier itself needs capabilities outside both TypeScript classes; the [App Model](/docs/app-model) page covers that wiring.
## Where the subset ends
The core tier covers app logic. The toolkit-extension tier — custom widgets, rasterizer work, host services, platform integration — is Zig by design: that layer is the machinery itself, and [Building Components](/docs/building-components) is its guide. A TypeScript app that needs one custom widget does not switch tiers wholesale; the widget is Zig, the core stays TypeScript.
The core owns app state and decisions — the app's deterministic logic. Services own imperative application work in ordinary TypeScript: parsing, filesystem transforms, environment inspection, subprocesses, and other ambient operations whose results cross back as messages. The toolkit-extension tier — custom widgets, rasterizer work, new engine-owned effects, and platform integration — remains Zig by design: that layer is the machinery itself, and [Building Components](/docs/building-components) is its guide. Services are not a backdoor storage engine or general FFI surface.
## Reference
The complete authoring guide — every rule ID, every Cmd shape, every subset corner with its idiom — ships as the `ts-core` agent skill: `native skills get ts-core`. It is written for AI agents and precise enough for humans.
The complete core guide ships as `native skills get ts-core`; typed service contracts, vendored npm, streaming, authority, and transport limits ship as `native skills get ts-services`. Both are written for AI agents and precise enough for humans.
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("typescript/services");
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}
@@ -0,0 +1,383 @@
# TypeScript Services
Modules under `src/services/` are ordinary TypeScript compiled to native code on the compiler's full static tier: `fs`, `path`, `process`, `os`, `child_process`, `fetch`, regexes, `JSON`, `Map`/`Set`, `Date`, and classes, when the pinned compiler supports them. The same pinned compiler builds the deterministic core ([TypeScript Cores](/docs/typescript)) and the services; no JavaScript engine ships in either.
The core calls a service by returning a command from `update`. The typed result returns as an ordinary `Msg`:
```ts:src/core.ts
import { feedsParse } from "@native-sdk/services";
case "parse":
return [model, feedsParse({ source: model.source, caseSensitive: false }, {
key: "parse",
ok: "parsed", // the one Msg arm carrying ParseResult
err: "parse_failed", // a one-Uint8Array-field arm
})];
```
Services run on a supervised carrier — on desktop, a separate child process by default or an explicitly selected worker-thread pool compiled into the app binary; on iOS and Android, the in-process pool only (see [Runtime behavior](#runtime-behavior)).
## The two roles
The split is by role: the core is the app's deterministic logic — `Model`, `Msg`, `update` — and services do the app's imperative work. Record→replay, headless testing, and [automation](/docs/automation) depend on `update` being a pure function of its inputs. A service reads the real filesystem, clock, and network, so the checker refuses a core import of a service file (NS1065) and the core-to-service edge is always a command. Service results are journaled like every other effect result.
<table>
<thead>
<tr>
<th>Role</th>
<th>Owns</th>
<th>Language rules</th>
</tr>
</thead>
<tbody>
<tr>
<td>Core (<code>src/core.ts</code> + imports outside <code>src/services/</code>)</td>
<td>App state and decisions: <code>Model</code>, <code>Msg</code>, <code>update</code>, pure helpers</td>
<td>The deterministic subset (NS1001NS1064)</td>
</tr>
<tr>
<td>Service (<code>src/services/**/*.ts</code>)</td>
<td>Imperative work: parsing, filesystem transforms, environment inspection, subprocesses</td>
<td>Ordinary static-tier TypeScript; only the boundary rules NS1065NS1067 apply</td>
</tr>
</tbody>
</table>
Services are not a storage engine or a general FFI surface. Durable data uses the engine-owned [persistence](/docs/persistence) and [record store](/docs/record-store) effects; custom widgets, render passes, and new engine capabilities are Zig ([Building Components](/docs/building-components)).
## Service authority
A service runs with the app's privileges. Its working directory is the app data directory. It may use:
- **Filesystem** — Node built-ins over the real disk.
- **Environment** — `process` and the allowlisted variables below.
- **Network** — `fetch` and sockets, directly.
- **Ambient time and randomness** — `Date.now()`, `Math.random()`, and friends. Their results reach the core only as journaled message payloads.
The child process receives an explicit environment allowlist; everything else, including every `NATIVE_SDK_*` internal, is stripped.
<table>
<thead>
<tr>
<th>Group</th>
<th>Variables</th>
</tr>
</thead>
<tbody>
<tr>
<td>Path</td>
<td><code>PATH</code></td>
</tr>
<tr>
<td>Home / user / temp</td>
<td><code>HOME</code>, <code>USER</code>, <code>TMPDIR</code>, <code>TMP</code>, <code>TEMP</code></td>
</tr>
<tr>
<td>Locale / time zone</td>
<td><code>LANG</code>, <code>LC_ALL</code>, <code>LC_CTYPE</code>, <code>TZ</code></td>
</tr>
<tr>
<td>Certificates</td>
<td><code>SSL_CERT_FILE</code>, <code>SSL_CERT_DIR</code></td>
</tr>
<tr>
<td>Proxies</td>
<td><code>HTTP_PROXY</code>, <code>HTTPS_PROXY</code>, <code>NO_PROXY</code></td>
</tr>
<tr>
<td>Windows additions</td>
<td><code>USERPROFILE</code>, <code>USERNAME</code>, <code>SystemRoot</code>, <code>COMSPEC</code>, <code>PATHEXT</code>; all names match case-insensitively</td>
</tr>
</tbody>
</table>
Standard output carries the framed transport between app and service, so service diagnostics go to standard error.
## Writing a service
A service module is any `.ts` file under `src/services/`. Every directly exported, non-default named function is an operation:
- It is synchronous and has a body.
- It takes zero or one explicitly annotated request parameter.
- It declares a contract-encodable result type.
- Its name is `<module-basename>.<export>` — `export function parse` in `src/services/feeds.ts` is `feeds.parse`.
Boundary shapes live in a shared, subset-legal module outside `src/services/`, imported by the core and the service:
```ts:src/shared.ts
export type ParseRequest = {
readonly source: Uint8Array;
readonly caseSensitive: boolean;
};
export type ParseResult = {
readonly bytes: Uint8Array;
readonly matches: boolean;
};
```
```ts:src/services/feeds.ts
import * as fs from "node:fs";
import type { ParseRequest, ParseResult } from "../shared.ts";
export function parse(request: ParseRequest): ParseResult {
if (!fs.existsSync(".")) {
throw { kind: "data_directory_missing", message: "the app data directory is unavailable" };
}
const source = new TextDecoder().decode(request.source);
const matches = request.caseSensitive ? /feed/.test(source) : /feed/i.test(source);
return { bytes: new TextEncoder().encode(JSON.stringify({ matches })), matches };
}
```
`native check` projects the complete type table into a contract sidecar (`services.contract.json`), checks both classes, and generates the typed client the core imports. An operation shaped any other way — `async`, a default export, an unannotated request, a non-encodable result — is a teaching error (NS1067) naming the rewrite.
### Boundary types
<table>
<thead>
<tr>
<th>Crosses</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>Booleans, numbers</td>
<td>Integer-class fields are proven and carried as integers</td>
</tr>
<tr>
<td><code>Uint8Array</code></td>
<td>The bytes form the core and services already share</td>
</tr>
<tr>
<td>Optionals, readonly slices</td>
<td><code>T | null</code> and <code>readonly T[]</code> of encodable elements</td>
</tr>
<tr>
<td>Named records, enums, kind-tagged unions</td>
<td>Declared in the shared module; both sides import the one declaration</td>
</tr>
<tr>
<td>Functions, behavior-bearing classes, Promises</td>
<td>Do not cross — the boundary is encoded data, not object references</td>
</tr>
</tbody>
</table>
Inside the service, classes, `Map`s, and the rest of the static tier are unrestricted; they just cannot be a request or result shape.
### Errors
An explicit throw crossing the operation boundary must be exactly an inline `{ kind: "...", message: "..." }` shape with a string-valued message, and it must escape the operation rather than be caught locally:
```ts
throw { kind: "parse", message: "bad feed" };
```
The encoded kind and message arrive on the core's error arm as UTF-8 JSON bytes. Do not throw `new Error(...)` from the exported surface. The build mechanically lowers the escaping tagged value into the form the pinned compiler carries across the boundary; your checked-in source — and its behavior under Node — does not change.
## Calling a service
`native check` derives the virtual module `@native-sdk/services` from the contract: one constructor per operation, named `<module><Export>` (`feeds.parse` → `feedsParse`), taking the typed request plus a route.
```ts:src/core.ts
import { feedsParse } from "@native-sdk/services";
import type { ParseRequest, ParseResult } from "./shared.ts";
export type Msg =
| { readonly kind: "parse"; readonly request: ParseRequest }
| { readonly kind: "parsed"; readonly result: ParseResult }
| { readonly kind: "parse_failed"; readonly error: Uint8Array };
case "parse":
return [model, feedsParse(msg.request, {
key: "feed-parse",
ok: "parsed",
err: "parse_failed",
})];
```
The route is typechecked: the constructor's type proves that `ok` names the one Msg arm carrying exactly the declared result record and that `err` names a one-bytes-field arm. A stale field or wrong route is a `native check` type error at the call site. The generated source lives only in build scratch space and the ignored editor package under `node_modules/@native-sdk/services`, never in authored `src/`.
Raw `Cmd.request("feeds.parse", bytes, { key?, ok, err })` remains the low-level byte seam beneath the client — same transport, same routing, request and result as raw bytes you encode yourself.
### Keys
Keys share the engine effect-key space. A second live request on the same key — buffered or streaming — is rejected (`err` receives `rejected`) rather than replacing the first, so two calls can never splice into one result. Cancel the first if you mean to supersede it.
### Timeouts
Every request carries a deadline: 30 seconds by default, or the operation's declared `@deadlineMs` (a JSDoc tag, 1 to 86400000 ms). Expiry routes JSON with `kind: "timeout"` to `err`.
### Cancellation
`Cmd.cancel(key)` on a buffered request drops it — no message is dispatched — and cooperatively interrupts the service child. Cancelling a stream routes `cancelled` to `err` (see [Streaming](#streaming)).
## Streaming
To return incremental results, declare a final typed `emit` capability. Each chunk arrives through a channel-event Msg arm; the function's return stays the one typed terminal result.
```ts:src/services/feeds.ts
import type { ServiceCancellation } from "@native-sdk/core";
import type { ParseChunk, ParseRequest, ParseResult } from "../shared.ts";
/**
* @deadlineMs 5000
* @streamBuffer 8
*/
export function parseLarge(
request: ParseRequest,
emit: (chunk: ParseChunk) => void,
cancellation: ServiceCancellation,
): ParseResult {
for (let index = 0; index < request.source.length; index += 4096) {
cancellation.throwIfCancelled();
emit({ bytes: request.source.slice(index, index + 4096), index });
}
return parse(request);
}
```
The generated route gains two fields beside `key`, `ok`, and `err`: `channelKey` (an app-chosen numeric channel key) and `event` (the channel-event Msg arm each chunk dispatches). The terminal result closes the channel after all accepted chunks. `@streamBuffer` caps in-flight chunks at 164 (default 8).
### Cooperative cancellation
An optional final `ServiceCancellation` parameter opts an operation into cooperative cancellation — legal only as the last parameter. Poll `cancelled()` or call `throwIfCancelled()` at bounded intervals.
- `Cmd.cancel(key)` on a stream flips the token, closes the channel, routes `cancelled` to `err`, and drops every later chunk.
- A deadline expiry flips the same token and routes `kind: "timeout"` to `err`.
- The child gets a short grace period to unwind and stays alive when it cooperates. An operation that ignores its token is hard-killed, and the next request starts a clean host.
## npm packages
Service modules may import local service files, shared core-class declarations, and exact vendored npm packages — never a bare install:
```bash
native vendor . escape-string-regexp@5.0.0
```
The command resolves the exact version once (lifecycle scripts disabled), copies the flattened package graph and license files into `src/services/vendor/`, and writes the exact name/version/tree-hash facts into `app.zon`. Check both in. Builds are hermetic: no npm, no network — every vendored byte is re-hashed, and the compiler receives only the explicit declared package allowlist. Importing a package that was never vendored is NS1066:
> Run `native vendor . package@X.Y.Z`, check in `src/services/vendor/` and the generated app.zon `service_packages` facts, then import that exact package name; or vendor a local source module and import it relatively.
npm support is selective. A vendored package compiles only if the pinned compiler reaches 100% static coverage of its bytes; anything less fails `native check` with the compiler's coverage note preserved verbatim and a remediation. The shipped compiler's calibration run over five deliberately small candidates passed three and refused two:
<table>
<thead>
<tr>
<th>Package</th>
<th>Verdict</th>
<th>Static coverage</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>escape-string-regexp@5.0.0</code></td>
<td>compiled</td>
<td>100%</td>
</tr>
<tr>
<td><code>comma-separated-tokens@2.0.3</code></td>
<td>compiled</td>
<td>100%</td>
</tr>
<tr>
<td><code>space-separated-tokens@2.0.2</code></td>
<td>compiled</td>
<td>100%</td>
</tr>
<tr>
<td><code>nanoid@3.3.15</code></td>
<td>refused</td>
<td>76%</td>
</tr>
<tr>
<td><code>micromark@4.0.2</code></td>
<td>refused</td>
<td>92%</td>
</tr>
</tbody>
</table>
Small, source-shipping, dependency-light utilities are the realistic fit. There is no `auto` mode or dynamic fallback; `native check` is the verdict for the exact bytes you vendored, and a refusal names the options: choose another exact package, port or vendor a suitable implementation, or wait for broader compiler support. Source you control — your own modules under `src/services/` — compiles on the same tier with no coverage question. For npm-heavy work that does not compile statically (an editor component, a charting stack), use a different edge: see [Where Packages Go](/docs/typescript/packages).
## Runtime behavior
Two carriers run the same operations behind the same routes, keys, deadlines, cancellation, streaming, and replay semantics. The build selects one:
<table>
<thead>
<tr>
<th>Carrier</th>
<th>Where services run</th>
<th>Selection</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>child</code></td>
<td>A second native executable — <code>&lt;app&gt;_services</code> — beside the app binary, packaged with it</td>
<td>Unset/<code>auto</code> default on desktop; unavailable on mobile</td>
</tr>
<tr>
<td><code>in_process</code></td>
<td>Compiled into the app binary; a small thread pool, one isolated module instance per thread</td>
<td>Explicit opt-in on native Linux, cross-Linux x86_64/aarch64, native Windows x86_64, cross-Windows x86_64 GNU, or macOS built on macOS; unset/<code>auto</code> default on iOS and Android</td>
</tr>
</tbody>
</table>
`.service_carrier = "in_process" | "child"` in app.zon (or `-Dservice-carrier`) states the choice. Unset/`"auto"` selects the child carrier on desktop and the in-process pool on iOS/Android, where a child process is unavailable. `.service_pool_size` (or `-Dservice-pool-size`, 1-16) sets the in-process pool width; the default is min(4, cores).
Shared guarantees:
- **Lazy start.** Nothing starts before the first real request — no child process, no pool thread. A session that never calls a service pays nothing.
- **Verified pairing.** The child's startup handshake checks the protocol version and a fingerprint of the generated operation/type/package registry; the in-process carrier checks the same fingerprint against the linked archive. A mismatch rejects before any operation dispatches.
- **Supervision.** Same-key requests run strictly FIFO. The in-process pool runs different keys in parallel across its instances; the child runs everything on one worker. A cancellation or deadline publishes the cooperative token and grants a short grace: an operation that returns inside it keeps its instance (or process) warm. Past the grace, the child is killed and respawns on the next request; the in-process carrier abandons the instance's thread, routes the failure, and adds a fresh instance to the pool. An abandoned dispatch keeps its key reserved until it physically stops, so a same-key replacement cannot overlap its side effects (and can itself expire while waiting). A detected trap poisons only the instance it fired in (`kind: "service_trap"`); other instances keep answering. Every failure produces a routed result: a dead transport `kind: "service_host"`, an expired deadline `kind: "timeout"`.
- **Replay.** Terminal results and stream events are journaled like every other effect. Replaying a recorded session parks each request and feeds the recorded result; neither carrier starts anything.
- **Scope.** Child executables are desktop-only and follow the pinned compiler's broad matrix: same-platform builds, Linux and Windows GNU targets cross-compiled from a macOS/Linux/Windows host, and macOS targets built on macOS. A Windows MSVC target builds natively on a matching Windows host; cross-Windows uses GNU because Zig supplies that target's CRT and system libraries. In-process archives use the compiler's narrower object-localization matrix: native Linux, cross-Linux x86_64/aarch64 (`aarch64-linux-android` included, API 26 floor, NDK required), native Windows x86_64, cross-Windows x86_64 GNU, or the Mach-O targets — macOS, `aarch64-ios`, and `aarch64-ios-simulator` (iOS 15.0 floor) — built on macOS. Mobile targets are archive-only: no sibling process exists there, so `service_carrier = "auto"` resolves to the in-process pool and an explicit `"child"` is refused with a teaching. A pairing outside the relevant matrix fails with a teaching, as does any explicitly spelled Linux `-gnu` target without a glibc version — even when it matches the build host, Zig's target uses its default floor. The service runtime needs glibc 2.36+ or musl, so explicit Linux targets are spelled `x86_64-linux-gnu.2.36` (or later) or `x86_64-linux-musl`. Operations are synchronous.
In-process specifics:
- Service code shares the app process: its ambient authority is the app's own (no environment allowlist, the app's working directory), and a hardware fault in service code — a stack overflow above all — is process-wide. The child carrier remains the fully isolated option.
- Each pool worker owns a separate instance of the service modules. Mutable module globals are worker-local, so different-key requests may observe different copies; keep shared durable state outside service-module globals.
- An abandoned instance's memory is reclaimed only at process exit; each trap or ignored token costs one leaked instance.
- `process.exit()` in service code exits the app.
## Development
`native dev --core` runs service operations in an isolated Node worker through the same generated contract: the same vendored-package hash verification, request/result codecs, error arms, cooperative cancellation and deadlines, and channel-event chunk shape. Pair `--script scenario.ndjson` with `--watch` for repeatable iteration.
The devhost honors session record/replay the same way the packaged runtime does — replay starts no service worker — and service-only recordings cross between the devhost and the packaged app. `native dev` runs the app with its build-selected carrier: the in-process pool linked into the binary, or the compiled service executable beside it.
## Boundary diagnostics
Three checker rules enforce the boundary. Each teaches the fix and the reason at the site.
<table>
<thead>
<tr>
<th>Rule</th>
<th>Teaching</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>NS1065</strong> — the core does not import services</td>
<td>A direct import would run ambient, non-deterministic service authority inside update and erase the command/result boundary that journaling and replay depend on. The core-to-service edge is always an effect.</td>
</tr>
<tr>
<td><strong>NS1066</strong> — service package imports are exact vendored facts</td>
<td>Service builds have no package-manager or network input: the compiler sees only manifest-declared, hash-verified checked-in sources through an explicit static-package allowlist.</td>
</tr>
<tr>
<td><strong>NS1067</strong> — service calls match the generated typed contract</td>
<td>The host codecs, runner registry, and typed client are projections of <code>services.contract.json</code>; every crossing data shape, stream declaration, deadline, and operation name must be stated there once.</td>
</tr>
</tbody>
</table>
## Reference
[`examples/service-feed-reader`](https://github.com/vercel-labs/native/tree/main/examples/service-feed-reader) is the complete loop as a small app: `Cmd.fetch` downloads a feed, the delivered bytes cross to `feeds.parse` through the generated typed client, the service's regex-and-`Map` parser returns shared `FeedResult` records the markup renders, and malformed input lands on the err arm as kind-tagged JSON. Its end-to-end suite (`tests/ts-services/feed_reader_e2e_tests.zig` in the SDK repo) records the whole loop against a loopback HTTP fixture and replays it byte-identically with the service executable absent. The machine-precise authoring guide ships as `native skills get ts-services`.
+22 -5
View File
@@ -8,7 +8,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
.title = "My App",
.width = 480,
.height = 320,
.restore_state = false,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
@@ -16,6 +15,8 @@ const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
The first scene window adopts the startup window; additional scene windows are created through the window service. Secondary windows can also be created imperatively from Zig — or, in apps that [embed web content](/docs/frontend), from trusted JavaScript.
Fresh windows use three-way placement. A successful state-store lookup restores the saved frame. Otherwise, an authored `x` or `y` places the window at that origin (constrained to the matching or nearest visible screen on macOS). With neither saved state nor an explicit origin, macOS centers the main window. Under the default `clamp_to_visible_screen` policy, restored frames stay on their matching or nearest display and fresh secondary windows cascade from the active window; `center_on_primary` centers restored and fresh default frames on the primary display. `restore_state = true` is only the persistence opt-in—it no longer makes a first launch look like a restored frame at `(0, 0)`.
## Overlay windows
Window creation accepts four overlay controls:
@@ -35,7 +36,6 @@ const overlay: native_sdk.ShellWindow = .{
.always_on_top = true,
.click_through = true,
.activate_on_show = false,
.restore_state = false,
.views = &.{.{
.label = "overlay-canvas",
.kind = .gpu_surface,
@@ -49,6 +49,18 @@ Canvas windows are created hidden and become visible after their first completed
On macOS these map to an alpha-capable floating `NSWindow`, ignored mouse events, and passive `orderFront`. The system WebView engine can participate in that alpha window; the Chromium engine rejects `transparent` because its windowed CEF browser cannot expose alpha to the parent window. Windows uses a layered topmost window with per-pixel alpha, transparent hit testing, a non-activating initial reveal, and normal activation if the user later clicks an interactive overlay. Its layered presenter composites multiple canvas surfaces in layer order. Because Win32 cannot redirect non-client chrome or child surfaces into the top-level alpha bitmap, a transparent Windows window must use `titlebar = .chromeless`, cannot be combined with application menus, and rejects WebViews and other native views. Linux provides transparency, input regions, and passive show on GTK; topmost is honored on X11 through `_NET_WM_STATE_ABOVE`. Wayland intentionally gives the compositor—not clients—control of topmost placement, so the GTK host reports that limitation and cannot guarantee `always_on_top` there.
## macOS menu-bar and login-item hooks
A TypeScript app does not need an AppKit sidecar for the usual menu-bar-app lifecycle:
- Set `initially_hidden = true` on the startup window to create its native host window ordered out. Unlike canvas present-before-show, no fallback deadline reveals it; `Cmd.showWindow(label)` or an explicit focus is required.
- Set `allows_fullscreen = false` on a settings window to keep ordinary resizing while removing macOS native-fullscreen participation and disabling the green fullscreen/zoom affordance. The flag is fixed when the window is created and is available on `app.zon` windows and `UiApp.WindowDescriptor`.
- Use `Cmd.hideWindow(label)` and `Cmd.showWindow(label)` to hide and restore a live window without destroying its views.
- Use `Cmd.setDockPresence(false)` for an accessory/headless Dock policy and `Cmd.setDockPresence(true)` to return to a regular Dock/app-switcher app.
- Use `Cmd.launchAtLoginStatus(route)` and `Cmd.setLaunchAtLogin(enabled, route)` for `SMAppService.mainApp`. The ok arm receives UTF-8 bytes containing `enabled`, `disabled`, `requires_approval`, or `not_found`; the err arm receives `unsupported`, `failed`, or `invalid_request`. `not_found` normally means the executable is not running from an installed app bundle, while `requires_approval` means the user must approve the item in System Settings.
The login-item calls are keyed effects, so their results record and replay like `Cmd.request`; Dock and window visibility changes are fire-and-forget commands. Older macOS versions without `SMAppService` report `unsupported` instead of requiring weak-linked application code.
## Creating windows from Zig
```zig
@@ -56,6 +68,7 @@ const info = try runtime.createWindow(.{
.label = "tools",
.title = "Tools",
.default_frame = native_sdk.geometry.RectF.init(80, 80, 420, 320),
.initial_placement = .explicit,
});
try runtime.focusWindow(info.id);
```
@@ -143,7 +156,7 @@ JavaScript creation also accepts `titlebar: "standard" | "hidden_inset" | "hidde
</tr>
<tr>
<td><code>WindowRestorePolicy</code></td>
<td>How restored frames are placed, such as clamping to the visible screen or centering on the primary display</td>
<td>How macOS places restored and fresh default frames: clamp to the matching/nearest visible screen or center on the primary display</td>
</tr>
<tr>
<td><code>WindowClosePolicy</code></td>
@@ -157,9 +170,13 @@ JavaScript creation also accepts `titlebar: "standard" | "hidden_inset" | "hidde
Each window declares what the user's close affordance (the red traffic light, cmd+W, the caption X) does, via `close_policy`:
- `"quit"` — the default and the classic behavior, unchanged for every existing app: the window really closes, and closing the last one follows the host's exit semantics.
- `"hide"` — the menu-bar/tray-app shape: the close affordance hides the window (it stays alive with its views; `WindowState.hidden` flips true on the frame channel, in the session journal, and in the `hidden` field of the window JSON the `window.zero.windows.*` bridge returns — `open` stays true, so `hidden` is what distinguishes a policy-hidden window from a visible unfocused one) and the app keeps running behind its status item. `fx.showWindow(label)` brings it back — a tray "Open" row's natural consequence — and on macOS the Dock-icon reopen re-shows it on its own. Runtime-initiated closes (`fx.closeWindow`, reconcile closes) still really close: the policy governs the USER's affordance, not the app's own decisions.
- `"hide"` — the menu-bar/tray-app shape: the close affordance hides the window (it stays alive with its views; `WindowState.hidden` flips true on the frame channel, in the session journal, and in the `hidden` field of the window JSON the `window.zero.windows.*` bridge returns — `open` stays true, so `hidden` is what distinguishes a policy-hidden window from a visible unfocused one) and the app keeps running behind its status item. `Cmd.showWindow(label)` brings it back — a tray "Open" row's natural consequence — and on macOS a visible Dock icon provides a second reopen path. Runtime-initiated closes (`Cmd.closeWindow`, reconcile closes) still really close: the policy governs the USER's affordance, not the app's own decisions.
`hide` is supported where the host can re-show a hidden window: macOS (`windowShouldClose` orders the window out; the Dock reopen path always exists) and Windows (`WM_CLOSE` hides via `SW_HIDE`; the tray icon is the ONLY re-show affordance, so on Windows `close_policy = "hide"` additionally requires the `"tray"` capability in app.zon — hiding removes the taskbar entry and Windows has no Dock-style reopen, so a declaration without the tray is refused at build time and at window create, exactly like Linux's refusal below; and if the declared tray fails to install at runtime, the first hide-close downgrades to a real close with a loud log rather than stranding an invisible process). Linux has no status item in this toolkit yet, so nothing could bring a hidden window back — the declaration is refused loudly at build time (a compile error for manifest windows) and at window create (`error.UnsupportedWindowClosePolicy`), never a silent no-op. The hidden state is session-transient: it never persists to the window-state store, so every launch starts shown.
`UiApp.WindowDescriptor.close_policy` exposes the same enum for model-declared secondary windows. Descriptor presence means the window remains alive: a `.hide` user close retains its slot and views and does not dispatch `on_close`; `showWindow(label)` reveals that same window. Stopping the declaration still performs a real reconcile close.
TypeScript cores expose the same contract as `windows(model): readonly WindowDescriptor[]`, with camel-case `closePolicy`. Each descriptor uses a literal `label: asciiBytes("<label>")` and the matching `src/windows/<label>.native`; check/build reject dynamic labels and missing roots. Those roots may import components nested under `src/windows/`. `titlebar` includes `"chromeless"`, including the transparent-Windows shape described above. Under `"quit"`, `onCloseCommand` routes through `commandMsg`; under `"hide"`, no close command fires because the window did not close.
`hide` is supported where the host can re-show a hidden window: macOS (`windowShouldClose` orders the window out) and Windows (`WM_CLOSE` hides via `SW_HIDE`; the tray icon is the ONLY re-show affordance, so on Windows `close_policy = "hide"` additionally requires the `"tray"` capability in app.zon — hiding removes the taskbar entry and Windows has no Dock-style reopen, so a declaration without the tray is refused at build time and at window create, exactly like Linux's refusal below; and if the declared tray fails to install at runtime, the first hide-close downgrades to a real close with a loud log rather than stranding an invisible process). On macOS the Dock reopen path exists only while `dock_visible = true`; an Accessory app has no Dock icon, so its status item must call `Cmd.showWindow(label)` to bring the window back. This is why `dock_visible = false` requires the `"tray"` capability. Linux has no status item in this toolkit yet, so nothing could bring a hidden window back — the declaration is refused loudly at build time (a compile error for manifest windows) and at window create (`error.UnsupportedWindowClosePolicy`), never a silent no-op. The hidden state is session-transient: it never persists to the window-state store, so every launch starts shown unless the manifest explicitly sets `initially_hidden = true`.
A policy-hidden window is occluded for pacing, exactly like a minimized one: its canvas frame completions drop to the ~1 Hz occluded heartbeat instead of the display grid (a menu-bar app parks hidden for days — full-rate frames there would be pure background CPU burn), and it does not count as a visible display for `.spectrum` emissions. `fx.showWindow` restores full cadence with the window.
+44 -40
View File
@@ -8,7 +8,8 @@ import { WindowDots } from "@/components/home/window-dots";
import { githubUrl, siteName } from "@/lib/site";
// ---------------------------------------------------------------- samples
// Both excerpts are real source from examples/ui-inbox in this repository.
// The markup is excerpted from examples/ui-inbox. The core excerpt shows
// the same loop in the default TypeScript authoring language.
const markupSample = `<column background="background">
<row height="{header_height}" padding="12" gap="10" cross="center"
@@ -47,37 +48,31 @@ const markupSample = `<column background="background">
<status-bar>{openCount} open · {doneCount} done</status-bar>
</column>`;
const zigSample = `pub const Msg = union(enum) {
add,
toggle: u32,
set_filter: Filter,
clear_done,
draft_edit: canvas.TextInputEvent,
chrome_changed: native_sdk.WindowChrome,
};
const tsSample = `export type Msg =
| { readonly kind: "add" }
| { readonly kind: "toggle"; readonly id: number }
| { readonly kind: "set_filter"; readonly filter: Filter }
| { readonly kind: "clear_done" }
| { readonly kind: "draft_edit"; readonly edit: TextInputEvent };
pub fn update(model: *Model, msg: Msg) void {
switch (msg) {
.add => {
if (model.draftEmpty()) {
model.addGeneratedTask();
} else {
model.addTask(std.mem.trim(u8, model.draft(), " "));
model.draft_buffer.clear();
}
},
.toggle => |id| if (model.taskById(id)) |task| {
task.done = !task.done;
},
.set_filter => |filter| model.filter = filter,
.clear_done => model.clearDone(),
.draft_edit => |edit| model.draft_buffer.apply(edit),
.chrome_changed => |chrome| {
model.chrome_leading = chrome.insets.left;
model.header_height =
@max(header_natural_height, chrome.insets.top);
},
}
export function update(model: Model, msg: Msg): Model {
switch (msg.kind) {
case "add":
return addTask(model);
case "toggle":
return {
...model,
tasks: model.tasks.map((task) =>
task.id === msg.id ? { ...task, done: !task.done } : task,
),
};
case "set_filter":
return { ...model, filter: msg.filter };
case "clear_done":
return { ...model, tasks: model.tasks.filter((task) => !task.done) };
case "draft_edit":
return { ...model, draft: applyDraftEdit(model.draft, msg.edit) };
}
}`;
// ------------------------------------------------------------ small parts
@@ -385,14 +380,14 @@ export default function HomePage() {
<SectionLede>
Events produce messages, messages update state, and state renders the interface
simple to debug, simple to maintain, and simple for AI to generate. This is{" "}
<InlineCode>examples/ui-inbox</InlineCode> from the repository: the whole UI is one
declarative view, and one update function is the only place state changes. Mistakes in
a view are compile errors with line and column, and in dev you edit the view while the
app runs, keeping state.
the default authoring shape: the whole UI is one declarative view, and one TypeScript
update function is the only place state changes. The TypeScript is compiled to native
code; no JavaScript runtime ships with the app. Mistakes in a view are compile errors
with line and column, and in dev you edit the view while the app runs, keeping state.
</SectionLede>
<div className="mt-10 grid gap-6 lg:grid-cols-2">
<CodePane title="src/inbox.native" lang="html" code={markupSample} />
<CodePane title="src/main.zig" lang="zig" code={zigSample} />
<CodePane title="src/app.native" lang="html" code={markupSample} />
<CodePane title="src/core.ts" lang="ts" code={tsSample} />
</div>
<figure className="mt-6">
<div className="mx-auto max-w-4xl rounded-md border border-gray-alpha-400 bg-gradient-to-b from-gray-100 to-background-200 p-6 sm:p-8 dark:from-gray-alpha-100 dark:to-background-100">
@@ -407,8 +402,8 @@ export default function HomePage() {
</div>
</div>
<figcaption className="mx-auto mt-4 max-w-3xl text-center copy-14 text-gray-900">
Built from the source above and captured running on macOS. The pixels come from{" "}
{siteName}s engine; the window and scroll physics come from the OS.
The <InlineCode>ui-inbox</InlineCode> reference captured running on macOS. The pixels
come from {siteName}s engine; the window and scroll physics come from the OS.
</figcaption>
</figure>
</div>
@@ -601,13 +596,19 @@ export default function HomePage() {
<Muted>a real window opens edit src/app.native while it runs</Muted>
</Terminal>
</div>
<div className="mt-8 flex items-center justify-center gap-3">
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Link
href="/docs/quick-start"
className="inline-flex h-10 items-center justify-center rounded-md bg-gray-1000 px-4 button-14 text-background-100 transition-colors hover:bg-gray-1000/85"
>
Quick Start
</Link>
<Link
href="/docs/typescript"
className="inline-flex h-10 items-center justify-center rounded-md border border-gray-alpha-400 bg-background-100 px-4 button-14 text-gray-1000 transition-colors hover:bg-gray-100"
>
TypeScript Cores
</Link>
<Link
href="/docs/native-ui"
className="inline-flex h-10 items-center justify-center rounded-md border border-gray-alpha-400 bg-background-100 px-4 button-14 text-gray-1000 transition-colors hover:bg-gray-100"
@@ -629,6 +630,9 @@ export default function HomePage() {
<Link href="/docs/native-ui" className="transition-colors hover:text-gray-1000">
Native UI
</Link>
<Link href="/docs/typescript" className="transition-colors hover:text-gray-1000">
TypeScript
</Link>
<Link href="/docs/automation" className="transition-colors hover:text-gray-1000">
Automation
</Link>
@@ -483,7 +483,12 @@ export function ComponentPreviewLive({
aria-label={`${alt} — interactive WASM preview`}
aria-roledescription="Interactive component preview rendered by the Native SDK engine. Press Escape to leave."
tabIndex={0}
className={`absolute inset-0 h-full w-full touch-none outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-700 ${
// DOM focus is the transport into the embedded engine, but the
// first Tab into a preview arrives before an engine widget owns
// focus. Keep an inset canvas outline for that entry state while
// cancelling the docs-wide [tabindex] shadow, whose overflow can
// otherwise appear as a stray stripe below the titlebar.
className={`absolute inset-0 h-full w-full touch-none outline-none focus-visible:shadow-none focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-700 ${
painted ? "opacity-100" : "opacity-0"
}`}
onPointerDown={(event) => {
+14 -6
View File
@@ -54,11 +54,11 @@
},
{
"name": "checkbox",
"doc": "Value control; bind checked, dispatch with on-toggle."
"doc": "Text-bearing value control; the visible label is text content (or text=), bind checked, dispatch with on-toggle."
},
{
"name": "radio",
"doc": "Value control; bind checked or selected, dispatch with on-toggle."
"doc": "Text-bearing single-choice value control; the visible label is text content (or text=), bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility."
},
{
"name": "toggle",
@@ -118,7 +118,7 @@
},
{
"name": "radio-group",
"doc": "Row container grouping radio controls; children flow horizontally."
"doc": "Logical radiogroup: give it an accessible label; descendant radios at any nesting depth share one Tab stop and selection, arrows wrap, and Home/End move to the scope edges."
},
{
"name": "tabs",
@@ -158,15 +158,15 @@
},
{
"name": "dialog",
"doc": "Modal dialog surface rendered in place; title via text, wrap in an if to show conditionally."
"doc": "Root-relative modal centered in the viewport and unaffected by ancestor scroll or clipping; title via text, wrap in an if to show conditionally."
},
{
"name": "drawer",
"doc": "Drawer surface rendered in place; title via text, wrap in an if to show conditionally."
"doc": "Root-relative modal spanning the viewport width and docked to its bottom; title via text, wrap in an if to show conditionally."
},
{
"name": "sheet",
"doc": "Sheet surface rendered in place; title via text, wrap in an if to show conditionally."
"doc": "Root-relative modal spanning the viewport height and docked to its right edge; title via text, wrap in an if to show conditionally."
},
{
"name": "resizable",
@@ -412,6 +412,10 @@
"name": "min-width",
"doc": "Width floor (plain number) without width's definite max: the element may grow past it but never shrink below. On split panes it bounds the divider drag."
},
{
"name": "max-width",
"doc": "Width ceiling (plain number) 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."
},
{
"name": "expanded",
"doc": "Tree rows (role=\"treeitem\"): disclosure state (true/false or a {binding}). Omit on leaves; expanded rows collapse on Left, collapsed ones expand on Right, both through on-toggle - the model owns the state."
@@ -428,6 +432,10 @@
"name": "autofocus",
"doc": "Focusable controls only: moves keyboard focus to the element when it mounts or when the value turns on (edge-triggered - holding it true never re-steals focus). The TEA way to focus an editor on create."
},
{
"name": "submit-on-enter",
"doc": "textarea only: true makes plain Enter dispatch on-submit while Shift+Enter inserts a newline; Cmd/Ctrl+Enter still submits. False or absent keeps the multiline default where Enter inserts and submission uses the primary chord."
},
{
"name": "icon",
"doc": "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 built-in name (comptime-validated against canvas.icons.known_icon_names, e.g. save, plus, refresh-cw), an app-registered app:<name>, or one {binding} resolving to such a name. Icon-only buttons when the content is empty — add a label. One hit target, one enabled/disabled tint."
+10
View File
@@ -27,6 +27,7 @@ const unprefixedNavSections: NavSection[] = [
items: [
{ name: "App Model", href: "/app-model" },
{ name: "TypeScript Cores", href: "/typescript" },
{ name: "TypeScript Services", href: "/typescript/services" },
{ name: "Where Packages Go", href: "/typescript/packages" },
{ name: "Native UI", href: "/native-ui" },
{ name: "Dynamic Images", href: "/dynamic-images" },
@@ -37,6 +38,15 @@ const unprefixedNavSections: NavSection[] = [
{ name: "Building Components", href: "/building-components" },
],
},
{
title: "Data",
items: [
{ name: "Model Persistence", href: "/persistence" },
{ name: "Record Store", href: "/record-store" },
{ name: "Relational SQLite", href: "/sqlite" },
{ name: "Files & Streaming", href: "/files" },
],
},
{
// One entry per built-in component page, generated from the shared
// components-pages inventory (previews regenerate via
+5
View File
@@ -9,9 +9,14 @@ export const PAGE_TITLES: Record<string, string> = {
"quick-start": "Quick Start",
"app-model": "App Model",
typescript: "TypeScript Cores",
"typescript/services": "TypeScript Services",
"typescript/packages": "Where Packages Go",
"native-ui": "Native UI",
"dynamic-images": "Dynamic Images",
persistence: "Model Persistence",
"record-store": "Record Store",
sqlite: "Relational SQLite",
files: "Files & Streaming",
terminal: "Terminal",
state: "State & Data Flow",
theming: "Theming",
+454 -22
View File
@@ -1,5 +1,5 @@
//! Decoder over the app-core Cmd/Sub wire format (rt.zig, cmd_format_version
//! 3), shared by the ts-track behavioral harnesses. The graders copy this
//! 7), shared by the ts-track behavioral harnesses. The graders copy this
//! file next to each case's harness so assertions read decoded ops — "a
//! fetch with key `feed` targeting this URL", "the delay re-armed" — instead
//! of hand-built byte strings, which keeps harnesses lenient about the parts
@@ -15,11 +15,29 @@ pub const Op = union(enum) {
now: struct { msg_tag: u8 },
host: Host,
host_bytes: struct { name: []const u8, payload: []const u8 },
request: struct { name: []const u8, key: []const u8, ok_tag: u8, err_tag: u8, payload: []const u8 },
request: struct { name: []const u8, key: []const u8, ok_tag: u8, err_tag: u8, typed_service: bool, payload: []const u8 },
service_stream_request: struct {
channel_key: f64,
event_tag: u8,
max_pending: u8,
name: []const u8,
key: []const u8,
ok_tag: u8,
err_tag: u8,
payload: []const u8,
},
cancel: struct { key: []const u8 },
read_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
write_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8, bytes: []const u8 },
append_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8, bytes: []const u8 },
stat_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
delete_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
read_file_stream: struct { key: []const u8, chunk_tag: u8, done_tag: u8, err_tag: u8, path: []const u8 },
write_file_stream: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
write_file_chunk: struct { key: []const u8, ok_tag: u8, err_tag: u8, bytes: []const u8 },
write_file_close: struct { key: []const u8, ok_tag: u8, err_tag: u8 },
fetch: Fetch,
fetch_stream: FetchStream,
clip_write: struct { bytes: []const u8 },
clip_read: struct { key: []const u8, ok_tag: u8, err_tag: u8 },
delay: struct { key: []const u8, after_ms: f64, msg_tag: u8 },
@@ -27,16 +45,26 @@ pub const Op = union(enum) {
audio_play: struct { key: []const u8, event_tag: u8, path: []const u8, url: []const u8, cache_path: []const u8, expected_bytes: f64 },
audio_ctl: struct { key: []const u8, verb: u8, value: f64 },
window_show: struct { label: []const u8 },
window_hide: struct { label: []const u8 },
dock_presence: struct { visible: bool },
store_set: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8, bytes: []const u8 },
store_get: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8 },
store_delete: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8 },
store_scan: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, prefix: []const u8, limit: u32, after: []const u8 },
store_set_many: StoreSetMany,
quit_app,
image_load: struct { id: f64, event_tag: u8, path: []const u8, url: []const u8, cache_path: []const u8, expected_bytes: f64 },
image_cancel: struct { id: f64 },
image_unregister: struct { id: f64 },
channel_open: struct { key: f64, event_tag: u8 },
channel_open: struct { key: f64, event_tag: u8, max_pending: u8 },
channel_close: struct { key: f64 },
pty_spawn: PtySpawn,
pty_write: struct { key: []const u8, bytes: []const u8 },
pty_resize: struct { key: []const u8, cols: f64, rows: f64 },
pty_kill: struct { key: []const u8 },
show_notification: struct { id: []const u8, title: []const u8, subtitle: []const u8, body: []const u8, action_label: []const u8, action_command: []const u8 },
audio_capture_start: struct { key: f64, source: u8, sample_rate: u32, channels: u8, event_tag: u8 },
audio_capture_stop: struct { key: f64 },
pub const Host = struct {
name: []const u8,
@@ -67,6 +95,21 @@ pub const Op = union(enum) {
body: []const u8,
};
pub const FetchStream = struct {
key: []const u8,
line_tag: u8,
ok_tag: u8,
err_tag: u8,
method: u8,
timeout_ms: u32,
max_line_bytes: u32,
url: []const u8,
header_count: u8,
/// Raw header block: per header [name_len u8][name][value_len u32 LE][value].
header_bytes: []const u8,
body: []const u8,
};
pub const Spawn = struct {
key: []const u8,
/// 0xFF = no line routing (rt.spawn_no_line_tag).
@@ -116,6 +159,26 @@ pub const Op = union(enum) {
}
}
};
pub const StoreSetMany = struct {
key: []const u8,
ok_tag: u8,
err_tag: u8,
scope: u32,
count: u32,
/// Raw entries: `[key_len u32][key][value_len u32][value]`.
entry_bytes: []const u8,
pub fn entry(self: StoreSetMany, index: usize) struct { key: []const u8, bytes: []const u8 } {
var off: usize = 0;
var i: usize = 0;
while (true) : (i += 1) {
const key = longBytes(self.entry_bytes, &off);
const bytes = longBytes(self.entry_bytes, &off);
if (i == index) return .{ .key = key, .bytes = bytes };
}
}
};
};
pub const CmdIter = struct {
@@ -157,9 +220,10 @@ pub const CmdIter = struct {
const key = shortBytes(b, &off);
const ok = b[off];
const err = b[off + 1];
off += 2;
const typed_service = b[off + 2] != 0;
off += 3;
const payload = longBytes(b, &off);
break :blk .{ .request = .{ .name = name, .key = key, .ok_tag = ok, .err_tag = err, .payload = payload } };
break :blk .{ .request = .{ .name = name, .key = key, .ok_tag = ok, .err_tag = err, .typed_service = typed_service, .payload = payload } };
},
0x06 => blk: {
const key = shortBytes(b, &off);
@@ -283,16 +347,15 @@ pub const CmdIter = struct {
off += 8;
break :blk .{ .image_unregister = .{ .id = id } };
},
// channel_open [op][key f64 LE][event_tag u8] — the bytes
// channel_open [op][key f64 LE][event_tag u8][max_pending u8] — the bytes
// rt.zig's cmdChannelOpen builds (ts_core_host.zig, 0x15).
// No max_pending rides the wire: the host opens with the
// engine default.
0x15 => blk: {
const key: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little));
off += 8;
const event_tag = b[off];
off += 1;
break :blk .{ .channel_open = .{ .key = key, .event_tag = event_tag } };
const max_pending = b[off + 1];
off += 2;
break :blk .{ .channel_open = .{ .key = key, .event_tag = event_tag, .max_pending = max_pending } };
},
// channel_close [op][key f64 LE] (ts_core_host.zig, 0x16).
0x16 => blk: {
@@ -344,6 +407,202 @@ pub const CmdIter = struct {
const key = shortBytes(b, &off);
break :blk .{ .pty_kill = .{ .key = key } };
},
// show_notification [op 0x1D][title/subtitle/body as u32-length
// bytes] (ts_core_host.zig, 0x1D).
0x1D => blk: {
const title = longBytes(b, &off);
const subtitle = longBytes(b, &off);
const body = longBytes(b, &off);
break :blk .{ .show_notification = .{ .id = "", .title = title, .subtitle = subtitle, .body = body, .action_label = "", .action_command = "" } };
},
// audio_capture_start [op 0x1E][key f64 LE][source u8]
// [sample_rate u32 LE][channels u8][event_tag u8].
0x1E => blk: {
const key: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little));
off += 8;
const source = b[off];
off += 1;
const sample_rate = std.mem.readInt(u32, b[off..][0..4], .little);
off += 4;
const channels = b[off];
const event_tag = b[off + 1];
off += 2;
break :blk .{ .audio_capture_start = .{
.key = key,
.source = source,
.sample_rate = sample_rate,
.channels = channels,
.event_tag = event_tag,
} };
},
// audio_capture_stop [op 0x1F][key f64 LE].
0x1F => blk: {
const key: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little));
off += 8;
break :blk .{ .audio_capture_stop = .{ .key = key } };
},
// fetch_stream [op 0x20][key][line/ok/err tags][method]
// [timeout u32 LE][max line u32 LE][url][headers][body]
// (ts_core_host.zig, 0x20).
0x20 => blk: {
const key = shortBytes(b, &off);
const line_tag = b[off];
const ok_tag = b[off + 1];
const err_tag = b[off + 2];
const method = b[off + 3];
off += 4;
const timeout = std.mem.readInt(u32, b[off..][0..4], .little);
off += 4;
const max_line_bytes = std.mem.readInt(u32, b[off..][0..4], .little);
off += 4;
const url = longBytes(b, &off);
const header_count = b[off];
off += 1;
const headers_start = off;
var h: usize = 0;
while (h < header_count) : (h += 1) {
_ = shortBytes(b, &off);
_ = longBytes(b, &off);
}
const header_bytes = b[headers_start..off];
const body = longBytes(b, &off);
break :blk .{ .fetch_stream = .{
.key = key,
.line_tag = line_tag,
.ok_tag = ok_tag,
.err_tag = err_tag,
.method = method,
.timeout_ms = timeout,
.max_line_bytes = max_line_bytes,
.url = url,
.header_count = header_count,
.header_bytes = header_bytes,
.body = body,
} };
},
// actionable_notification [op 0x31][id/title/subtitle/body/
// action_label/action_command as u32-length bytes].
0x31 => blk: {
const id = longBytes(b, &off);
const title = longBytes(b, &off);
const subtitle = longBytes(b, &off);
const body = longBytes(b, &off);
const action_label = longBytes(b, &off);
const action_command = longBytes(b, &off);
break :blk .{ .show_notification = .{
.id = id,
.title = title,
.subtitle = subtitle,
.body = body,
.action_label = action_label,
.action_command = action_command,
} };
},
// window_hide [op 0x21][label_len u8][label].
0x21 => blk: {
const label = shortBytes(b, &off);
break :blk .{ .window_hide = .{ .label = label } };
},
// dock_presence [op 0x22][visible u8].
0x22 => blk: {
const visible = b[off] != 0;
off += 1;
break :blk .{ .dock_presence = .{ .visible = visible } };
},
// Atomic typed streaming-service admission.
0x28 => blk: {
const channel_key: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little));
off += 8;
const event_tag = b[off];
const max_pending = b[off + 1];
off += 2;
const name = shortBytes(b, &off);
const key = shortBytes(b, &off);
const ok_tag = b[off];
const err_tag = b[off + 1];
off += 2;
const payload = longBytes(b, &off);
break :blk .{ .service_stream_request = .{
.channel_key = channel_key,
.event_tag = event_tag,
.max_pending = max_pending,
.name = name,
.key = key,
.ok_tag = ok_tag,
.err_tag = err_tag,
.payload = payload,
} };
},
0x23 => blk: {
const head = routedHead(b, &off);
const scope = readU32(b, &off);
const store_key = longBytes(b, &off);
const bytes = longBytes(b, &off);
break :blk .{ .store_set = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key, .bytes = bytes } };
},
0x24 => blk: {
const head = routedHead(b, &off);
const scope = readU32(b, &off);
const store_key = longBytes(b, &off);
break :blk .{ .store_get = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key } };
},
0x25 => blk: {
const head = routedHead(b, &off);
const scope = readU32(b, &off);
const store_key = longBytes(b, &off);
break :blk .{ .store_delete = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key } };
},
0x26 => blk: {
const head = routedHead(b, &off);
const scope = readU32(b, &off);
const prefix = longBytes(b, &off);
const limit = readU32(b, &off);
const after = longBytes(b, &off);
break :blk .{ .store_scan = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .prefix = prefix, .limit = limit, .after = after } };
},
0x27 => blk: {
const head = routedHead(b, &off);
const scope = readU32(b, &off);
const count = readU32(b, &off);
const entries_start = off;
for (0..count) |_| {
_ = longBytes(b, &off);
_ = longBytes(b, &off);
}
break :blk .{ .store_set_many = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .count = count, .entry_bytes = b[entries_start..off] } };
},
0x2B => blk: {
const head = routedHead(b, &off);
break :blk .{ .append_file = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off), .bytes = longBytes(b, &off) } };
},
0x2C => blk: {
const head = routedHead(b, &off);
break :blk .{ .stat_file = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off) } };
},
0x2D => blk: {
const key = shortBytes(b, &off);
const chunk_tag = b[off];
const done_tag = b[off + 1];
const err_tag = b[off + 2];
off += 3;
break :blk .{ .read_file_stream = .{ .key = key, .chunk_tag = chunk_tag, .done_tag = done_tag, .err_tag = err_tag, .path = longBytes(b, &off) } };
},
0x2E => blk: {
const head = routedHead(b, &off);
break :blk .{ .write_file_stream = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off) } };
},
0x2F => blk: {
const head = routedHead(b, &off);
break :blk .{ .write_file_chunk = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .bytes = longBytes(b, &off) } };
},
0x30 => blk: {
const head = routedHead(b, &off);
break :blk .{ .write_file_close = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err } };
},
0x32 => blk: {
const head = routedHead(b, &off);
break :blk .{ .delete_file = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .path = longBytes(b, &off) } };
},
else => std.debug.panic("cmdview: unknown op byte 0x{X:0>2} at offset {d}", .{ op, self.off }),
};
self.off = off;
@@ -405,6 +664,12 @@ fn longBytes(b: []const u8, off: *usize) []const u8 {
return out;
}
fn readU32(b: []const u8, off: *usize) u32 {
const value = std.mem.readInt(u32, b[off.*..][0..4], .little);
off.* += 4;
return value;
}
// ------------------------------------------------------------------ helpers
/// First decoded op of the given kind in a cmd buffer, or null.
@@ -464,6 +729,62 @@ test "window_show and quit_app decode, alone and inside a batch" {
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
test "window_hide and dock_presence decode, alone and inside a batch" {
const hidden = findOp(&.{ 0x21, 4, 'm', 'a', 'i', 'n' }, .window_hide) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("main", hidden.label);
const dock = findOp(&.{ 0x22, 0 }, .dock_presence) orelse return error.TestUnexpectedResult;
try std.testing.expect(!dock.visible);
const batch = [_]u8{ 0x21, 3, 'h', 'u', 'd', 0x22, 1, 0x02, 9 };
var iter = CmdIter.init(&batch);
try std.testing.expectEqualStrings("hud", (iter.next() orelse return error.TestUnexpectedResult).window_hide.label);
try std.testing.expect((iter.next() orelse return error.TestUnexpectedResult).dock_presence.visible);
try std.testing.expectEqual(@as(u8, 9), (iter.next() orelse return error.TestUnexpectedResult).now.msg_tag);
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
test "record store command records decode and advance exactly" {
const batch = [_]u8{
0x23, 1, 'r', 2, 3, 0, 0, 0, 0, 1, 0, 0, 0, 'k', 1, 0, 0, 0, 'v',
0x26, 0, 4, 5, 0, 0, 0, 0, 2, 0, 0, 0, 'p', '/', 7, 0, 0, 0, 1,
0, 0, 0, 'a', 0x27, 1, 'b', 6, 7, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0,
0, 0, 'x', 2, 0, 0, 0, 8, 9, 0x02, 10,
};
var iter = CmdIter.init(&batch);
const set = (iter.next() orelse return error.TestUnexpectedResult).store_set;
try std.testing.expectEqualStrings("r", set.key);
try std.testing.expectEqualStrings("k", set.store_key);
try std.testing.expectEqualSlices(u8, "v", set.bytes);
const scan = (iter.next() orelse return error.TestUnexpectedResult).store_scan;
try std.testing.expectEqualStrings("p/", scan.prefix);
try std.testing.expectEqual(@as(u32, 7), scan.limit);
try std.testing.expectEqualStrings("a", scan.after);
const many = (iter.next() orelse return error.TestUnexpectedResult).store_set_many;
try std.testing.expectEqual(@as(u32, 1), many.count);
try std.testing.expectEqualStrings("x", many.entry(0).key);
try std.testing.expectEqualSlices(u8, &.{ 8, 9 }, many.entry(0).bytes);
try std.testing.expectEqual(@as(u8, 10), (iter.next() orelse return error.TestUnexpectedResult).now.msg_tag);
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
test "delete_file decodes and advances a batch exactly" {
const batch = [_]u8{
0x32, 4, 'f', 'i', 'l', 'e', 2, 3,
12, 0, 0, 0, 'o', 'b', 's', 'o',
'l', 'e', 't', 'e', '.', 'b', 'i', 'n',
0x02, 7,
};
var iter = CmdIter.init(&batch);
const deleted = (iter.next() orelse return error.TestUnexpectedResult).delete_file;
try std.testing.expectEqualStrings("file", deleted.key);
try std.testing.expectEqual(@as(u8, 2), deleted.ok_tag);
try std.testing.expectEqual(@as(u8, 3), deleted.err_tag);
try std.testing.expectEqualStrings("obsolete.bin", deleted.path);
try std.testing.expectEqual(@as(u8, 7), (iter.next() orelse return error.TestUnexpectedResult).now.msg_tag);
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
test "the image records decode, alone and inside a batch" {
// image_load: [op 0x12][id f64 LE][event_tag][path][url][cache]
// [expected f64 LE] — the bytes rt.zig's cmdImageLoad pins (the same
@@ -503,15 +824,16 @@ test "the image records decode, alone and inside a batch" {
}
test "the channel records decode, alone and inside a batch" {
// channel_open: [op 0x15][key f64 LE][event_tag u8] — the bytes
// rt.zig's cmdChannelOpen pins (no max_pending on the wire).
var open_bytes: [10]u8 = undefined;
// channel_open: [op 0x15][key f64 LE][event_tag u8][max_pending u8].
var open_bytes: [11]u8 = undefined;
open_bytes[0] = 0x15;
open_bytes[1..9].* = @bitCast(@as(f64, 41));
open_bytes[9] = 5; // event_tag
open_bytes[10] = 7; // max_pending
const opened = findOp(&open_bytes, .channel_open) orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(f64, 41), opened.key);
try std.testing.expectEqual(@as(u8, 5), opened.event_tag);
try std.testing.expectEqual(@as(u8, 7), opened.max_pending);
// channel_close: [op 0x16][key f64 LE].
var close_bytes: [9]u8 = undefined;
@@ -521,13 +843,13 @@ test "the channel records decode, alone and inside a batch" {
try std.testing.expectEqual(@as(f64, 41), closed.key);
// A batch of open + close + a trailing now record: each record must
// advance the iterator exactly its own length (ten bytes, then
// advance the iterator exactly its own length (eleven bytes, then
// nine) for the tail to decode.
var batch: [21]u8 = undefined;
batch[0..10].* = open_bytes;
batch[10..19].* = close_bytes;
batch[19] = 0x02;
batch[20] = 7;
var batch: [22]u8 = undefined;
batch[0..11].* = open_bytes;
batch[11..20].* = close_bytes;
batch[20] = 0x02;
batch[21] = 7;
var iter = CmdIter.init(&batch);
const first = iter.next() orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(f64, 41), first.channel_open.key);
@@ -538,6 +860,70 @@ test "the channel records decode, alone and inside a batch" {
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
test "typed request metadata and atomic streaming service records decode" {
const a = std.testing.allocator;
var bytes: std.ArrayList(u8) = .empty;
defer bytes.deinit(a);
try bytes.append(a, 0x05);
try bytes.append(a, 5);
try bytes.appendSlice(a, "parse");
try bytes.append(a, 3);
try bytes.appendSlice(a, "one");
try bytes.appendSlice(a, &.{ 4, 5, 1, 2, 0, 0, 0, 'o', 'k' });
const request = findOp(bytes.items, .request) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("parse", request.name);
try std.testing.expect(request.typed_service);
try std.testing.expectEqualStrings("ok", request.payload);
bytes.clearRetainingCapacity();
try bytes.append(a, 0x28);
try bytes.appendSlice(a, &@as([8]u8, @bitCast(@as(f64, 79))));
try bytes.appendSlice(a, &.{ 6, 3 });
try bytes.append(a, 6);
try bytes.appendSlice(a, "stream");
try bytes.append(a, 3);
try bytes.appendSlice(a, "two");
try bytes.appendSlice(a, &.{ 7, 8, 3, 0, 0, 0, 1, 2, 3 });
const stream = findOp(bytes.items, .service_stream_request) orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(f64, 79), stream.channel_key);
try std.testing.expectEqual(@as(u8, 6), stream.event_tag);
try std.testing.expectEqual(@as(u8, 3), stream.max_pending);
try std.testing.expectEqualStrings("stream", stream.name);
try std.testing.expectEqualStrings("two", stream.key);
try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3 }, stream.payload);
}
test "the audio capture records decode and advance a batch exactly" {
var start: [16]u8 = undefined;
start[0] = 0x1E;
start[1..9].* = @bitCast(@as(f64, 91));
start[9] = 1; // system
std.mem.writeInt(u32, start[10..14], 24_000, .little);
start[14] = 2;
start[15] = 6;
const opened = findOp(&start, .audio_capture_start) orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(f64, 91), opened.key);
try std.testing.expectEqual(@as(u8, 1), opened.source);
try std.testing.expectEqual(@as(u32, 24_000), opened.sample_rate);
try std.testing.expectEqual(@as(u8, 2), opened.channels);
try std.testing.expectEqual(@as(u8, 6), opened.event_tag);
var stop: [9]u8 = undefined;
stop[0] = 0x1F;
stop[1..9].* = @bitCast(@as(f64, 91));
var batch: [27]u8 = undefined;
batch[0..16].* = start;
batch[16..25].* = stop;
batch[25..27].* = .{ 0x02, 7 };
var iter = CmdIter.init(&batch);
_ = iter.next() orelse return error.TestUnexpectedResult;
const closed = iter.next() orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(f64, 91), closed.audio_capture_stop.key);
try std.testing.expectEqual(@as(u8, 7), (iter.next() orelse return error.TestUnexpectedResult).now.msg_tag);
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
test "the pty records decode, alone and inside a batch" {
const a = std.testing.allocator;
@@ -571,9 +957,10 @@ test "the pty records decode, alone and inside a batch" {
try std.testing.expectEqualStrings("-l", spawned.arg(1));
// pty_write [0x1A][key][bytes u32-len], pty_resize [0x1B][key]
// [cols f64 LE][rows f64 LE], pty_kill [0x1C][key], and a trailing
// now record in one batch: each record must advance the iterator
// exactly its own length for the tail to decode.
// [cols f64 LE][rows f64 LE], pty_kill [0x1C][key], notification
// [0x1D][title][subtitle][body], and a trailing now record in one batch:
// each record must advance the iterator exactly its own length for the
// tail to decode.
var batch: std.ArrayList(u8) = .empty;
defer batch.deinit(a);
try batch.append(a, 0x1A);
@@ -589,6 +976,13 @@ test "the pty records decode, alone and inside a batch" {
try batch.append(a, 0x1C);
try batch.append(a, 5);
try batch.appendSlice(a, "shell");
try batch.append(a, 0x1D);
try batch.appendSlice(a, &.{ 5, 0, 0, 0 });
try batch.appendSlice(a, "Ready");
try batch.appendSlice(a, &.{ 3, 0, 0, 0 });
try batch.appendSlice(a, "SDK");
try batch.appendSlice(a, &.{ 4, 0, 0, 0 });
try batch.appendSlice(a, "Done");
try batch.appendSlice(a, &.{ 0x02, 7 });
var iter = CmdIter.init(batch.items);
const wrote = iter.next() orelse return error.TestUnexpectedResult;
@@ -599,7 +993,45 @@ test "the pty records decode, alone and inside a batch" {
try std.testing.expectEqual(@as(f64, 40), resized.pty_resize.rows);
const killed = iter.next() orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("shell", killed.pty_kill.key);
const notification = iter.next() orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("Ready", notification.show_notification.title);
try std.testing.expectEqualStrings("SDK", notification.show_notification.subtitle);
try std.testing.expectEqualStrings("Done", notification.show_notification.body);
const tail = iter.next() orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(u8, 7), tail.now.msg_tag);
try std.testing.expectEqual(@as(?Op, null), iter.next());
}
test "streaming fetch decodes its routes and limits" {
const a = std.testing.allocator;
var bytes: std.ArrayList(u8) = .empty;
defer bytes.deinit(a);
try bytes.append(a, 0x20);
try bytes.append(a, 4);
try bytes.appendSlice(a, "chat");
try bytes.appendSlice(a, &.{ 7, 8, 9, 1 });
try bytes.appendSlice(a, &.{ 0x88, 0x13, 0, 0 }); // 5000 ms
try bytes.appendSlice(a, &.{ 0, 0x20, 0, 0 }); // 8192 bytes
try bytes.appendSlice(a, &.{ 15, 0, 0, 0 });
try bytes.appendSlice(a, "https://ai.test");
try bytes.append(a, 1);
try bytes.append(a, 6);
try bytes.appendSlice(a, "accept");
try bytes.appendSlice(a, &.{ 17, 0, 0, 0 });
try bytes.appendSlice(a, "text/event-stream");
try bytes.appendSlice(a, &.{ 2, 0, 0, 0 });
try bytes.appendSlice(a, "{}");
const stream = findOp(bytes.items, .fetch_stream) orelse return error.TestUnexpectedResult;
try std.testing.expectEqualStrings("chat", stream.key);
try std.testing.expectEqual(@as(u8, 7), stream.line_tag);
try std.testing.expectEqual(@as(u8, 8), stream.ok_tag);
try std.testing.expectEqual(@as(u8, 9), stream.err_tag);
try std.testing.expectEqual(@as(u8, 1), stream.method);
try std.testing.expectEqual(@as(u32, 5000), stream.timeout_ms);
try std.testing.expectEqual(@as(u32, 8192), stream.max_line_bytes);
try std.testing.expectEqualStrings("https://ai.test", stream.url);
try std.testing.expectEqual(@as(u8, 1), stream.header_count);
try std.testing.expectEqualStrings("{}", stream.body);
}
+17 -3
View File
@@ -10,7 +10,22 @@ native build # produce a ReleaseFast binary in zig-out/bin/
(In this repository the CLI is `zig-out/bin/native`, built by `zig build` at the root.) A handful of examples own a `build.zig` because they genuinely outgrow the generated graph — each one's build file opens with the reason.
## Zero-config apps (native-rendered)
## Start here: TypeScript + Native markup
TypeScript is the primary app-authoring language. A new `native init my_app` project has `src/core.ts`, `src/app.native`, and `app.zon`; the core compiles ahead of time to native code, so no JS runtime ships in the app. These examples are the clearest substantial references for that path:
| Example | Shows |
| --- | --- |
| `chatbot` | Multi-module TypeScript core, text editing, streaming `Cmd.fetch`, environment messages, and deterministic replay. |
| `service-feed-reader` | The complete services loop: `Cmd.fetch`, a parsing service reached through the generated `@native-sdk/services` client, shared record shapes, and recorded replay without the service. |
| `relational-notes` | Append-only SQLite migrations, build-time checked SQL, generated typed transactions and page decoders, FTS5, and live queries. |
| `gpu-components` | Isolated interactive Native UI specimens, disclosure trees, anchored menus, and controlled component state. |
| `soundboard-ts` | Full music player: audio effects, timers, search, assets, native context menus, and adaptive markup. |
| `system-monitor-ts` | Subprocess effects, timers, parsing, tables, charts, controlled scroll, and confirmation flows. |
The `-ts` suffix is historical: `soundboard-ts` and `system-monitor-ts` distinguish ports from older Zig originals in the same catalog. Chatbot was introduced as a TypeScript-only example and follows the unsuffixed default. New TypeScript apps need no suffix because TypeScript is the default. Many unsuffixed showcase apps predate that default and still use `src/main.zig`; use them for their feature or visual patterns, not as evidence that new app logic should be Zig.
## Earlier native-rendered showcase apps (Zig cores)
| Example | Shows |
| --- | --- |
@@ -26,7 +41,6 @@ native build # produce a ReleaseFast binary in zig-out/bin/
| `system-monitor` | Live process sampling, confirmation dialogs, a settings window. |
| `gpu-surface` | A Metal-backed GPU surface composed beside native controls and WebView content. |
| `gpu-dashboard` | Native chrome, a GPU surface, and a retained canvas display list. |
| `gpu-components` | The retained GPU widget controls in one native-first component lab. |
| `canvas-preview` | Canvas + WebView in one window, panes snapped to canvas anchors, a status item. |
| `effects-probe` | The effect system live: spawn/fetch/file effects, cancellation, worker wakes. |
| `menu-bar` | The menu-bar app lifecycle: `close_policy = "hide"`, a status item whose Open/Quit rows drive `fx.showWindow`/`fx.quitApp`, Dock reopen. |
@@ -48,4 +62,4 @@ native build # produce a ReleaseFast binary in zig-out/bin/
`mobile-shell`, `ios`, and `android` are mobile host projects (Xcode/Gradle shells plus shared `app.zon` metadata) rather than desktop app directories.
Start with `habits` for the native-rendered markup path, or `hello` for the WebView path. Move to `webview` when you need native commands or WebView policy, `capabilities` for guarded OS services, the GPU trio when you want custom-rendered or retained-canvas panes, and a frontend example when building a real web frontend.
Start with `native init` for a small TypeScript + Native markup app, then use `chatbot`, `gpu-components`, `soundboard-ts`, or `system-monitor-ts` according to the feature you need. Use `habits` when you specifically want the smallest Zig-core equivalent, `hello` for the lower-level WebView path, `webview` for native commands or WebView policy, `capabilities` for guarded OS services, and `gpu-surface` or `gpu-dashboard` for custom-rendered or retained-canvas panes.
-44
View File
@@ -1,44 +0,0 @@
# Native SDK ai-chat-ts example
A chat client for an OpenAI-compatible chat-completions endpoint, authored entirely in **TypeScript + Native markup**. Zero Zig: the logic tier is the app-core subset under `src/`, compiled to native code at build time; `src/app.native` is the whole view tier and `app.zon` the manifest. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
This is the reference answer to "can a TypeScript core call an AI API?": the network surface is one `Cmd.fetch` with a real `Authorization: Bearer <key>` header built at runtime from the launch environment, the JSON wire format is pure byte math in the subset, and because the whole exchange is effect data, a recorded conversation **replays byte-identically with zero network and zero env reads** — the e2e suite pins the exact request bytes and replays a two-turn conversation, transport failure and retry included, with no endpoint in the room and none of the launch variables set.
The core is two modules plus one SDK library:
- `src/core.ts` — the entry module: Model (the conversation, the composer, the request phase, the launch configuration), Msg, update, the env channel, and every exported binding helper.
- `src/api.ts` — the chat-completions wire format over bytes: request encoding (JSON escaping included) and response parsing (`choices[0].message.content` on success, `error.message` on failure; anything malformed is `null`, never a half-parsed conversation).
- `@native-sdk/core/text` — the SDK's byte-splice text engine, compiled in for the composer's caret/selection/IME fidelity.
```sh
NATIVE_SDK_CHAT_ENDPOINT="http://127.0.0.1:11434/v1/chat/completions" \
NATIVE_SDK_CHAT_MODEL="<your model name>" \
NATIVE_SDK_CHAT_API_KEY="local" \
native dev # run the real app
native dev --core --script dev-script.ndjson # the core-logic loop under node - no renderer, no network
native check # subset-check the core's import graph + markup + app.zon
```
The end-to-end proof battery lives in the SDK repo (`tests/ts-core/ai_chat_e2e_tests.zig`, run by `zig build test-ts-core-e2e`): it drives this example's real core and shipping markup headlessly through the teaching state (zero fetches without configuration), a scripted conversation with the request bytes pinned (`Authorization` header included), the in-flight guard, every failure shape, and record→replay with the launch variables unset and changed.
## Configuration: the env channel
The endpoint, model, and key arrive through the core's `envMsgs` channel — one journaled Msg per variable at install. The core never reads the environment (that would break determinism), **no endpoint is baked in, and no key exists anywhere in this tree**: until all three variables are present and non-empty, the app shows a setup panel naming exactly what is missing and issues zero requests.
- **`NATIVE_SDK_CHAT_ENDPOINT`** — the full chat-completions URL (for a local runtime, typically `http://127.0.0.1:<port>/v1/chat/completions`).
- **`NATIVE_SDK_CHAT_MODEL`** — the model name the endpoint expects in the request body.
- **`NATIVE_SDK_CHAT_API_KEY`** — the bearer token, sent as a standard `Authorization: Bearer <key>` header. Local OpenAI-compatible runtimes ignore auth; any placeholder satisfies the guard.
Record/replay journals these deliveries: a session recorded with the variables set replays byte-identically on a machine where they are unset or different — the recorded values feed from the journal, and replay never reads the environment.
## Where this example is honest about v1 boundaries
Every line below is a decided posture, listed on purpose:
- **The reply arrives whole, not streamed.** `Cmd.fetch` is buffered by design in v1 — one request, one `{ status, body }` result Msg. The UI shows an honest waiting state instead of a token stream. The effect engine underneath already frames streamed response bodies into line Msgs (the Zig effects channel's `.stream` fetch — exactly the shape SSE token streams arrive in); surfacing that in the TS Cmd vocabulary is the named roadmap item. Buffered is also what makes the replay trick trivial: one journaled result per request.
- **A failed request keeps the conversation.** Every failure shape — a non-200 status (the endpoint's own `error.message` surfaces when the body carries one), a 200 whose body does not parse, a transport failure with its machine-readable reason — lands in one failed state with the history intact and a Retry that re-sends the same conversation.
- **One request in flight, by construction.** `phase === "sending"` guards every send path in update (the Send button binds the same guard), and the `"chat"` effect key would reject a duplicate at the engine even if update misbehaved. A send blocked by the guard loses nothing — the draft survives.
- **Long conversations eventually hit the request bound.** The engine's fetch body bound is 64 KiB; a conversation that outgrows it is rejected by the engine at runtime and lands in the failed state with a reason. Clear starts fresh. (History trimming/summarizing is app policy, deliberately not built in here.)
- **The conversation is not persisted.** The Model is the session; `Cmd.writeFile` + a boot-time `Cmd.readFile` is the standard persistence pattern when an app wants history across launches.
- **Desktop only.** TypeScript cores build desktop apps today.
- **The encoder's helpers return byte arrays instead of appending to a shared buffer.** Local mutation ends at the first escape — an array passed to another function is no longer yours to mutate (the NS1051 "mutates after the array escaped" rule) — so `encodeChatRequest` assembles the request from values its helpers return, in one literal, rather than handing a parts buffer around between pushes.
-39
View File
@@ -1,39 +0,0 @@
# The chat client's core-logic loop, headless: replay with
# native dev --core --script dev-script.ndjson
# Msgs dispatch into update; the endpoint's answers are ordinary Msgs, so
# the responses are fed back by hand exactly as the transcript's
# `cmd fetch ...` lines invite — the same loop the native app runs, with
# you standing in for the network.
# The launch configuration arrives through the env channel as ordinary
# Msgs (under the real app the generated wiring dispatches these from the
# environment at install). A local placeholder endpoint - nothing dials
# out under the core host.
{"kind":"endpoint_set","value":{"$bytes":"http://127.0.0.1:11434/v1/chat/completions"}}
{"kind":"model_set","value":{"$bytes":"local-model"}}
{"kind":"key_set","value":{"$bytes":"local"}}
# Type a message (the composer runs the SDK byte-splice text engine) and
# send. The transcript shows the fetch command whole: POST, the endpoint,
# the runtime-built "authorization: Bearer <key>" header, and the JSON
# body - system prompt first, then the history.
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"Say hi in two words"}}}
{"kind":"send"}
# The endpoint's answer, fed back by hand: choices[0].message.content
# parses into the assistant turn (escapes decode - note the \n).
{"kind":"chat_response","status":200,"body":{"$bytes":"{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Hi\\nthere!\"}}]}"}}
# A second turn grows the history: watch the request body carry both
# earlier turns before the new question.
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"And a follow-up?"}}}
{"kind":"send"}
# This time the endpoint fails with its own error body - the failed
# state keeps the history and surfaces error.message as the reason.
{"kind":"chat_response","status":500,"body":{"$bytes":"{\"error\":{\"message\":\"model overloaded\",\"type\":\"server_error\"}}"}}
# Retry re-sends the SAME conversation (no new turn); a success resolves
# it into the fourth turn.
{"kind":"retry"}
{"kind":"chat_response","status":200,"body":{"$bytes":"{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Certainly.\"}}]}"}}
-120
View File
@@ -1,120 +0,0 @@
<!-- The chat client's whole view tier: one markup file over the TS core's
model, bound by the names core.ts wrote (fields and exported helpers
bind verbatim). The
header carries the model badge and Clear, the conversation is a
controlled scroll of role bubbles (user right/accent, assistant
left/surface) with honest sending and failed rows, and the composer
is a text-field on the core's byte-splice engine — Enter and the
Send button dispatch the same `send` arm. Until the three launch
variables arrive through the env channel, the teaching panel
explains the setup and the app issues zero requests. -->
<column background="background">
<row height="52" padding="12" gap="10" cross="center" background="surface" label="Chat header">
<text label="AI Chat"><span weight="bold" scale="1.1">AI Chat</span></text>
<badge variant="secondary">{modelLabel}</badge>
<spacer grow="1" />
<if test="{sending}">
<text size="sm" foreground="text_muted">waiting for the model…</text>
</if>
<button size="sm" variant="ghost" icon="trash" disabled="{clearDisabled}" on-press="clear" label="Clear conversation">Clear</button>
</row>
<separator />
<if test="{unconfigured}">
<!-- The teaching state: no endpoint is baked in and no request ever
leaves an unconfigured app — the panel names exactly what is
missing. -->
<column grow="1" padding="24" main="center" cross="center" label="Setup">
<panel padding="24" background="surface" radius="lg" width="520" label="Connect a model">
<column gap="12">
<text><span weight="bold">Connect a model</span></text>
<text size="sm" foreground="text_muted">This app talks to an OpenAI-compatible chat-completions endpoint. Set all three variables and relaunch — the core reads them once, at install, through the env channel.</text>
<row gap="8" cross="center">
<text size="sm" grow="1">NATIVE_SDK_CHAT_ENDPOINT</text>
<if test="{endpointMissing}">
<text size="sm" foreground="destructive">missing</text>
</if>
<else>
<text size="sm" foreground="success">set</text>
</else>
</row>
<row gap="8" cross="center">
<text size="sm" grow="1">NATIVE_SDK_CHAT_MODEL</text>
<if test="{modelMissing}">
<text size="sm" foreground="destructive">missing</text>
</if>
<else>
<text size="sm" foreground="success">set</text>
</else>
</row>
<row gap="8" cross="center">
<text size="sm" grow="1">NATIVE_SDK_CHAT_API_KEY</text>
<if test="{keyMissing}">
<text size="sm" foreground="destructive">missing</text>
</if>
<else>
<text size="sm" foreground="success">set</text>
</else>
</row>
<text size="sm" foreground="text_muted">The README shows the full setup, including local OpenAI-compatible runtimes.</text>
</column>
</panel>
</column>
</if>
<else>
<scroll grow="1" label="Conversation" value="{chatScrollTop}" on-scroll="chat_scrolled">
<column padding="24" gap="10">
<if test="{emptyConversation}">
<panel padding="24" background="surface" radius="lg" label="Empty conversation">
<column gap="6">
<text>Ask anything</text>
<text size="sm" foreground="text_muted">The reply arrives whole — responses are buffered in v1, not streamed.</text>
</column>
</panel>
</if>
<for each="turnRows" as="t" key="id">
<if test="{t.user}">
<row key="{t.id}" gap="8" label="You said">
<spacer grow="1" min-width="64" />
<panel padding="12" background="accent" radius="lg">
<text wrap="true" foreground="accent_text">{t.text}</text>
</panel>
</row>
</if>
<else>
<row key="{t.id}" gap="8" label="The model said">
<panel padding="12" background="surface" radius="lg">
<text wrap="true">{t.text}</text>
</panel>
<spacer grow="1" min-width="64" />
</row>
</else>
</for>
<if test="{sending}">
<row gap="8" label="Reply pending">
<panel padding="12" background="surface" radius="lg">
<text foreground="text_muted">…</text>
</panel>
<spacer grow="1" min-width="64" />
</row>
</if>
<if test="{failed}">
<panel padding="12" background="surface" radius="lg" label="Request failed">
<row gap="10" cross="center">
<icon name="alert" width="14" height="14" foreground="destructive" />
<column gap="2" grow="1">
<text size="sm" foreground="destructive">Request failed</text>
<text size="sm" foreground="text_muted">{failReasonLabel}</text>
</column>
<button size="sm" variant="ghost" icon="refresh-cw" on-press="retry" label="Retry request">Retry</button>
</row>
</panel>
</if>
</column>
</scroll>
<separator />
<row padding="12" gap="8" cross="center" background="surface" label="Composer">
<text-field grow="1" text="{draftText}" placeholder="Message the model…" on-input="draft_edit" on-submit="send" label="Message" />
<button variant="primary" icon="send" disabled="{sendDisabled}" on-press="send" label="Send message">Send</button>
</row>
</else>
</column>
-409
View File
@@ -1,409 +0,0 @@
// ai-chat-ts core: a chat client for an OpenAI-compatible chat-completions
// endpoint, authored entirely in the TypeScript app-core subset. Zero Zig
// in this tree: the build transpiles this module and src/api.ts,
// src/app.native is the whole view, app.zon the manifest.
//
// The core is two modules plus one SDK library, all under src/:
//
// core.ts (this file) Model, Msg, update, the wiring channels, and
// every exported binding helper — the entry module is the
// app's public face (markup and node both see its exports)
// api.ts the chat-completions wire format in pure bytes: request
// encoding, response parsing (choices[0].message.content and
// error.message — exactly the fields the app reads)
// @native-sdk/core/text the SDK's byte-splice text engine, transpiled
// in for the composer's caret/selection/IME fidelity
//
// The whole network surface is ONE effect: `Cmd.fetch` on the "chat" key,
// buffered (fetch streaming is consciously not in v1 — the reply arrives
// whole; the README frames the roadmap). The in-flight discipline is
// model-first: `phase === "sending"` blocks every re-send in update, so a
// second request cannot exist while one is out — and the "chat" key backs
// that up at the engine (a duplicate live key would be rejected, never
// doubled).
//
// The endpoint, model name, and API key arrive through the `envMsgs`
// channel as journaled Msgs at install — the core never reads the
// environment (NS1005), which is exactly why a recorded conversation
// replays byte-identically on a machine with none of the variables set.
import { Cmd, asciiBytes, type EnvMsg } from "@native-sdk/core";
import {
applyTextInputEvent,
clampedInsertEvent,
trimAsciiSpaces,
type TextEditState,
type TextInputEvent,
} from "@native-sdk/core/text";
// The SDK-provided scroll-state record (the shape markup's on-scroll
// matches structurally - imported, so no in-file mirror can drift).
import { type ScrollState } from "@native-sdk/core/events";
import {
bearerToken,
encodeChatRequest,
parseChatContent,
parseErrorMessage,
type Bytes,
type Turn,
} from "./api.ts";
/// The conversation's standing instruction, first in every request's
/// message list. One constant, versioned with the app — not model state,
/// so replay and the request pins never depend on it drifting.
const SYSTEM_PROMPT = asciiBytes(
"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.",
);
/// The composer's byte capacity — comfortably under the engine's 64 KiB
/// request-body bound with a long conversation around it.
const MAX_DRAFT = 4096;
/// Assigning the scroll binding a value past the content clamps to the
/// bottom — how a new message keeps the latest turn in view.
const SCROLL_BOTTOM = 1000000;
// -------------------------------------------------------------- composer
// The fixed-capacity editor state for the message field: the SDK text
// engine does the byte splicing; this wrapper is the app's flat committed
// shape for it (compStart -1 = no composition). Immutable: composerApply
// returns a new value.
export interface ComposerDraft {
readonly bytes: Bytes;
readonly anchor: number;
readonly focus: number;
readonly compStart: number; // -1 when no composition
readonly compEnd: number;
}
function composerInit(): ComposerDraft {
return { bytes: new Uint8Array(0), anchor: 0, focus: 0, compStart: -1, compEnd: -1 };
}
function composerState(d: ComposerDraft): TextEditState {
return {
text: d.bytes,
selection: { anchor: d.anchor, focus: d.focus },
composition: d.compStart >= 0 ? { start: d.compStart, end: d.compEnd } : null,
};
}
function composerApply(d: ComposerDraft, event: TextInputEvent): ComposerDraft {
const state = composerState(d);
const next = applyTextInputEvent(state, event, MAX_DRAFT);
if (next === null) {
// Over-capacity: clamp an insert to the bytes that fit (refuse-whole
// for everything else) — the runtime TextBuffer's contract.
const clamped = clampedInsertEvent(state, event, MAX_DRAFT);
if (clamped === null) return d;
const nextClamped = applyTextInputEvent(state, clamped, MAX_DRAFT);
if (nextClamped === null) return d;
// Composition bounds land in i64-classed slots: bind them, guard
// the range (an ordered comparison excludes NaN), and state
// wholeness with Math.trunc at the write; -1 stays the no-composition
// sentinel.
const clampedStart = nextClamped.composition !== null ? nextClamped.composition.start : -1;
const clampedEnd = nextClamped.composition !== null ? nextClamped.composition.end : -1;
return {
bytes: nextClamped.text,
anchor: nextClamped.selection.anchor,
focus: nextClamped.selection.focus,
compStart: clampedStart >= -1 && clampedStart <= 9007199254740991 ? Math.trunc(clampedStart) : -1,
compEnd: clampedEnd >= -1 && clampedEnd <= 9007199254740991 ? Math.trunc(clampedEnd) : -1,
};
}
const nextStart = next.composition !== null ? next.composition.start : -1;
const nextEnd = next.composition !== null ? next.composition.end : -1;
return {
bytes: next.text,
anchor: next.selection.anchor,
focus: next.selection.focus,
compStart: nextStart >= -1 && nextStart <= 9007199254740991 ? Math.trunc(nextStart) : -1,
compEnd: nextEnd >= -1 && nextEnd <= 9007199254740991 ? Math.trunc(nextEnd) : -1,
};
}
// ------------------------------------------------------------------ model
export type Phase = "idle" | "sending" | "failed";
export interface Model {
/// The conversation, oldest first — user and assistant turns alike.
/// Committed state, so record→replay carries the whole conversation.
readonly turns: readonly Turn[];
readonly nextId: number;
/// The request lifecycle: `sending` is the in-flight guard (every
/// re-send path checks it), `failed` keeps the history and shows the
/// reason until the next send.
readonly phase: Phase;
/// Why the last request failed: the transport reason (`timed_out`,
/// `connect_failed`, ...), the endpoint's own error.message, or the
/// HTTP status line — never empty in the failed phase.
readonly failReason: Bytes;
readonly draft: ComposerDraft;
/// The launch configuration (the envMsgs channel): the full
/// chat-completions URL, the model name, and the API key. All three
/// empty until their variables arrive; the app teaches setup until
/// every one is non-empty.
readonly endpoint: Bytes;
readonly modelName: Bytes;
readonly apiKey: Bytes;
/// The conversation scroll offset, echoed from markup's `on-scroll`
/// and pushed past the content on every new turn (the clamp lands it
/// at the bottom) — the controlled-scroll shape.
readonly chatScrollTop: number;
}
export function initialModel(): Model {
return {
turns: [],
nextId: 1,
phase: "idle",
failReason: new Uint8Array(0),
draft: composerInit(),
endpoint: new Uint8Array(0),
modelName: new Uint8Array(0),
apiKey: new Uint8Array(0),
chatScrollTop: 0,
};
}
// -------------------------------------------------------------------- msg
export type Msg =
| { readonly kind: "draft_edit"; readonly edit: TextInputEvent }
/// The send gesture: the composer's Enter (markup `on-submit`) and the
/// Send button dispatch the same arm.
| { readonly kind: "send" }
/// Re-issue the failed request over the history as it stands (the
/// unanswered user turn is already the last entry).
| { readonly kind: "retry" }
| { readonly kind: "clear" }
/// The delivered HTTP response, any status — the fetch ok arm.
| { readonly kind: "chat_response"; readonly status: number; readonly body: Bytes }
/// The transport failure — the fetch err arm's machine-readable reason.
| { readonly kind: "chat_failed"; readonly reason: Bytes }
| { readonly kind: "chat_scrolled"; readonly scroll: ScrollState }
| { readonly kind: "endpoint_set"; readonly value: Bytes }
| { readonly kind: "model_set"; readonly value: Bytes }
| { readonly kind: "key_set"; readonly value: Bytes };
// --------------------------------------------------- host-event channels
/// The launch configuration channel: each variable present at launch
/// dispatches one journaled Msg right after boot. NO default endpoint
/// and NO baked key exist anywhere in this tree — an unconfigured app
/// says so on screen instead of dialing a stranger.
export const envMsgs: readonly EnvMsg<Msg>[] = [
{ env: "NATIVE_SDK_CHAT_ENDPOINT", msg: "endpoint_set" },
{ env: "NATIVE_SDK_CHAT_MODEL", msg: "model_set" },
{ env: "NATIVE_SDK_CHAT_API_KEY", msg: "key_set" },
];
/// Update-only state: host-fired Msg arms and the fields markup reads
/// through the exported derived helpers instead of directly.
export const viewUnbound = [
"chat_response",
"chat_failed",
"endpoint_set",
"model_set",
"key_set",
"turns",
"nextId",
"phase",
"failReason",
"draft",
"endpoint",
"modelName",
"apiKey",
] as const;
// ---------------------------------------------------------------- derived
function isConfigured(model: Model): boolean {
return model.endpoint.length > 0 && model.modelName.length > 0 && model.apiKey.length > 0;
}
/// The teaching state: some launch variable is missing, so the app can
/// only explain how to connect a model — and issues zero requests.
export function unconfigured(model: Model): boolean {
return !isConfigured(model);
}
export function endpointMissing(model: Model): boolean {
return model.endpoint.length === 0;
}
export function modelMissing(model: Model): boolean {
return model.modelName.length === 0;
}
export function keyMissing(model: Model): boolean {
return model.apiKey.length === 0;
}
export function sending(model: Model): boolean {
return model.phase === "sending";
}
export function failed(model: Model): boolean {
return model.phase === "failed";
}
export function failReasonLabel(model: Model): Bytes {
return model.failReason;
}
export function draftText(model: Model): Bytes {
return model.draft.bytes;
}
export function emptyConversation(model: Model): boolean {
return model.turns.length === 0;
}
/// The header's model badge: the configured name, or the gap it teaches.
export function modelLabel(model: Model): Bytes {
return model.modelName.length > 0 ? model.modelName : asciiBytes("no model configured");
}
export function sendDisabled(model: Model): boolean {
return model.phase === "sending" || !isConfigured(model);
}
export function clearDisabled(model: Model): boolean {
return model.phase === "sending" || model.turns.length === 0;
}
/// One conversation row for markup's `for each`: the role flag picks the
/// bubble side and colors.
export interface TurnRow {
readonly id: number;
readonly user: boolean;
readonly text: Bytes;
}
export function turnRows(model: Model): readonly TurnRow[] {
return model.turns.map((t) => ({ id: t.id, user: t.role === "user", text: t.text }));
}
// ----------------------------------------------------------------- update
export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
switch (msg.kind) {
case "draft_edit":
return [{ ...model, draft: composerApply(model.draft, msg.edit) }, Cmd.none];
case "send": {
// The in-flight guard: one request at a time, by model state — a
// second send while one is out is a no-op, so the "chat" key can
// never collide at the engine.
if (!isConfigured(model) || model.phase === "sending") return [model, Cmd.none];
const text = trimAsciiSpaces(model.draft.bytes);
if (text.length === 0) return [model, Cmd.none];
const turns: readonly Turn[] = [...model.turns, { id: model.nextId, role: "user", text: text }];
return [
{
...model,
turns: turns,
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
phase: "sending",
failReason: new Uint8Array(0),
draft: composerInit(),
chatScrollTop: SCROLL_BOTTOM,
},
Cmd.fetch(
{
url: model.endpoint,
method: "POST",
// The bearer token is a RUNTIME header value (built from the
// launch-supplied key); header names stay compile-time.
headers: { authorization: bearerToken(model.apiKey), "content-type": "application/json" },
body: encodeChatRequest(model.modelName, SYSTEM_PROMPT, turns),
timeoutMs: 120000,
},
{ key: "chat", ok: "chat_response", err: "chat_failed" },
),
];
}
case "retry": {
// Re-send the conversation as it stands: only from the failed
// state, and only when the last turn is the unanswered user turn.
if (model.phase !== "failed" || !isConfigured(model)) return [model, Cmd.none];
if (model.turns.length === 0) return [model, Cmd.none];
if (model.turns[model.turns.length - 1].role !== "user") return [model, Cmd.none];
return [
{ ...model, phase: "sending", failReason: new Uint8Array(0) },
Cmd.fetch(
{
url: model.endpoint,
method: "POST",
headers: { authorization: bearerToken(model.apiKey), "content-type": "application/json" },
body: encodeChatRequest(model.modelName, SYSTEM_PROMPT, model.turns),
timeoutMs: 120000,
},
{ key: "chat", ok: "chat_response", err: "chat_failed" },
),
];
}
case "clear": {
if (model.phase === "sending" || model.turns.length === 0) return [model, Cmd.none];
return [{
...model,
turns: [],
nextId: 1,
phase: "idle",
failReason: new Uint8Array(0),
chatScrollTop: 0,
}, Cmd.none];
}
case "chat_response": {
// The "chat" key carries exactly one live request and the sending
// guard blocks re-sends, so a response outside the sending phase
// can only be stale — drop it rather than corrupt the history.
if (model.phase !== "sending") return [model, Cmd.none];
if (msg.status === 200) {
const content = parseChatContent(msg.body);
if (content === null) {
// A 200 whose body is not a chat completion is a failed
// request, never a half-parsed conversation.
return [{
...model,
phase: "failed",
failReason: asciiBytes("the response did not parse as a chat completion"),
}, Cmd.none];
}
return [{
...model,
turns: [...model.turns, { id: model.nextId, role: "assistant", text: content }],
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
phase: "idle",
chatScrollTop: SCROLL_BOTTOM,
}, Cmd.none];
}
// Any other status is a delivered response whose meaning is "the
// endpoint said no": surface its own error.message when the body
// carries one, the bare status line when it does not.
const message = parseErrorMessage(msg.body);
return [{
...model,
phase: "failed",
failReason: message ?? asciiBytes(`the endpoint answered HTTP ${msg.status}`),
}, Cmd.none];
}
case "chat_failed":
// The transport reason is machine-readable (`timed_out`,
// `connect_failed`, `truncated`, ...) — shown as-is, never silence.
return [{ ...model, phase: "failed", failReason: msg.reason }, Cmd.none];
case "chat_scrolled":
// The controlled-scroll echo: the applied offset lands in the
// model, so the next rebuild's `value` binding never fights the
// runtime.
return [{ ...model, chatScrollTop: msg.scroll.offsetY }, Cmd.none];
case "endpoint_set":
return [{ ...model, endpoint: msg.value }, Cmd.none];
case "model_set":
return [{ ...model, modelName: msg.value }, Cmd.none];
case "key_set":
return [{ ...model, apiKey: msg.value }, Cmd.none];
}
}
@@ -24,6 +24,8 @@ enum {
NATIVE_SDK_WIDGET_ROLE_SWITCH = 17,
NATIVE_SDK_WIDGET_ROLE_SLIDER = 18,
NATIVE_SDK_WIDGET_ROLE_PROGRESSBAR = 19,
NATIVE_SDK_WIDGET_ROLE_RADIO = 20,
NATIVE_SDK_WIDGET_ROLE_RADIOGROUP = 21,
};
enum {
@@ -266,6 +268,7 @@ int native_sdk_app_text_input_state(void *app, native_sdk_text_input_state_t *ou
typedef double (*native_sdk_text_measure_fn)(void *context, uint64_t font_id, double size, const char *text, uintptr_t text_len);
int native_sdk_app_set_text_measure(void *app, native_sdk_text_measure_fn measure, void *context);
int native_sdk_app_set_automation_dir(void *app, const char *path, uintptr_t len);
int native_sdk_app_set_data_root(void *app, const char *path, uintptr_t len);
int native_sdk_app_render_pixel_size(void *app, float scale, native_sdk_canvas_pixels_t *out);
int native_sdk_app_render_pixels(void *app, float scale, uint8_t *pixels, uintptr_t pixels_len, native_sdk_canvas_pixels_t *out);
// Incremental sibling of native_sdk_app_render_pixels for a host that
@@ -248,7 +248,7 @@ class MainActivity : Activity(), SurfaceHolder.Callback {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
info.stateDescription = widgetStateDescription(node)
}
info.isCheckable = node.role == WIDGET_ROLE_CHECKBOX || node.role == WIDGET_ROLE_SWITCH
info.isCheckable = node.role == WIDGET_ROLE_CHECKBOX || node.role == WIDGET_ROLE_RADIO || node.role == WIDGET_ROLE_SWITCH
info.isChecked = info.isCheckable && widgetValueSelected(node)
info.isClickable = widgetSupportsAnyAction(node, WIDGET_ACTION_PRESS or WIDGET_ACTION_TOGGLE or WIDGET_ACTION_SELECT)
info.isEditable = node.role == WIDGET_ROLE_TEXTBOX && (node.flags and WIDGET_FLAG_READ_ONLY) == 0
@@ -347,6 +347,8 @@ class MainActivity : Activity(), SurfaceHolder.Callback {
WIDGET_ROLE_BUTTON, WIDGET_ROLE_MENUITEM -> "android.widget.Button"
WIDGET_ROLE_TEXTBOX -> "android.widget.EditText"
WIDGET_ROLE_CHECKBOX -> "android.widget.CheckBox"
WIDGET_ROLE_RADIO -> "android.widget.RadioButton"
WIDGET_ROLE_RADIOGROUP -> "android.widget.RadioGroup"
WIDGET_ROLE_SWITCH -> "android.widget.Switch"
WIDGET_ROLE_SLIDER -> "android.widget.SeekBar"
WIDGET_ROLE_PROGRESSBAR -> "android.widget.ProgressBar"
@@ -784,6 +786,8 @@ class MainActivity : Activity(), SurfaceHolder.Callback {
private const val WIDGET_ROLE_SWITCH = 17
private const val WIDGET_ROLE_SLIDER = 18
private const val WIDGET_ROLE_PROGRESSBAR = 19
private const val WIDGET_ROLE_RADIO = 20
private const val WIDGET_ROLE_RADIOGROUP = 21
private const val WIDGET_FLAG_FOCUSED = 1 shl 0
private const val WIDGET_FLAG_SELECTED = 1 shl 3
private const val WIDGET_FLAG_DISABLED = 1 shl 4
+3
View File
@@ -204,9 +204,12 @@ fn linkPlatform(b: *std.Build, target: std.Build.ResolvedTarget, app_mod: *std.B
},
}
if (b.sysroot) |sysroot| app_mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "System/Library/Frameworks" }) });
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/capture_info_plist.c"), .flags = &.{} });
app_mod.linkFramework("AppKit", .{});
// The audio playback service (the AppKit host's single AVPlayer).
app_mod.linkFramework("AVFoundation", .{});
app_mod.linkFramework("CoreMedia", .{});
app_mod.linkFramework("ScreenCaptureKit", .{ .weak = true });
// CVPixelBuffer for the video frame path (the AppKit host's
// AVPlayerItemVideoOutput frames are real CoreVideo symbols).
app_mod.linkFramework("CoreVideo", .{});
+1
View File
@@ -118,6 +118,7 @@ fn manifestWindow(comptime window: anytype, comptime index: usize) native_sdk.Wi
.resizable = windowBool(window, "resizable", true),
.restore_state = windowBool(window, "restore_state", true),
.restore_policy = windowRestorePolicy(window),
.initial_placement = if (@hasField(@TypeOf(window), "x") or @hasField(@TypeOf(window), "y")) .explicit else .default,
};
}

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