Compare commits

...

26 Commits

Author SHA1 Message Date
Chris Tate bb97110886 docs(windows): clarify restore policy support 2026-08-17 17:33:51 -05:00
Chris Tate 7379550a27 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.
2026-08-17 17:08:33 -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
371 changed files with 14918 additions and 1619 deletions
+3
View File
@@ -74,6 +74,9 @@ jobs:
- 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).
+73 -3
View File
@@ -2,12 +2,84 @@
All notable changes to the Native SDK (formerly zero-native) will be documented in this file.
## 0.9.0
## 0.9.3
<!-- release:start -->
### 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
<!-- release:end -->
## 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).
@@ -34,8 +106,6 @@ All notable changes to the Native SDK (formerly zero-native) will be documented
- @Railly
- @camilocbarrera
<!-- release:end -->
## 0.8.4
### New Features
+276 -12
View File
@@ -198,9 +198,46 @@ pub fn build(b: *std.Build) void {
desktop_mod.addIncludePath(b.path("third_party/sqlite"));
desktop_mod.addCSourceFile(.{
.file = b.path("third_party/sqlite/sqlite3.c"),
.flags = sqliteCompileFlags(),
// Mobile -Dtargets (the mobile e2e battery) compile the
// amalgamation against the platform SDK/NDK sysroot; desktop
// targets keep Zig's ordinary libc discovery.
.flags = if (target.result.os.tag == .ios or target.result.abi.isAndroid())
@import("build/app.zig").sqliteCFlags(b, target)
else
sqliteCompileFlags(),
});
const app_runner_window_placement_mod = module(b, target, optimize, "src/app_runner/window_placement.zig");
app_runner_window_placement_mod.addImport("native_sdk", desktop_mod);
const app_runner_window_placement_tests = testArtifact(b, app_runner_window_placement_mod);
const app_runner_options = b.addOptions();
app_runner_options.addOption([]const u8, "platform", "null");
app_runner_options.addOption([]const u8, "trace", "off");
app_runner_options.addOption([]const u8, "web_engine", "system");
app_runner_options.addOption(bool, "debug_overlay", false);
app_runner_options.addOption(bool, "automation", false);
app_runner_options.addOption(bool, "web_layer", false);
const app_runner_mod = module(b, target, optimize, "src/app_runner/root.zig");
app_runner_mod.addImport("native_sdk", desktop_mod);
app_runner_mod.addImport("build_options", app_runner_options.createModule());
app_runner_mod.addImport("app_manifest_zon", b.createModule(.{ .root_source_file = b.path("tests/app-runner/menu_commands_fixture.zon") }));
const app_runner_migrations_mod = module(b, target, optimize, "src/app_runner/no_migrations.zig");
app_runner_migrations_mod.addImport("native_sdk", desktop_mod);
app_runner_mod.addImport("relational_migrations", app_runner_migrations_mod);
const app_runner_tests = testArtifact(b, app_runner_mod);
const app_runner_test_run = b.addRunArtifact(app_runner_tests);
const app_runner_test_step = b.step("test-app-runner", "Run framework app-runner manifest fallback tests");
app_runner_test_step.dependOn(&app_runner_test_run.step);
desktop_mod.link_libc = true;
if (target.result.os.tag == .macos) {
const flags: []const []const u8 = if (b.sysroot) |sysroot|
&.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0", "-isysroot", sysroot, b.fmt("-I{s}/usr/include", .{sysroot}) }
else
&.{ "-fobjc-arc", "-fno-sanitize=builtin", "-ObjC", "-mmacosx-version-min=11.0" };
desktop_mod.addCSourceFile(.{ .file = b.path("src/platform/macos/image_fit_test.m"), .flags = flags });
desktop_mod.linkFramework("Foundation", .{});
desktop_mod.linkFramework("ImageIO", .{});
desktop_mod.linkSystemLibrary("objc", .{});
}
const desktop_tests = testArtifact(b, desktop_mod);
const desktop_test_shards = desktopTestShardArtifacts(b, desktop_mod);
// Tier-5 crash battery: a child uses the public streamed sink, signals
@@ -587,6 +624,8 @@ pub fn build(b: *std.Build) void {
test_step.dependOn(&b.addRunArtifact(platform_info_tests).step);
test_step.dependOn(&b.addRunArtifact(json_tests).step);
test_step.dependOn(&b.addRunArtifact(app_runner_assets_tests).step);
test_step.dependOn(&b.addRunArtifact(app_runner_window_placement_tests).step);
test_step.dependOn(&app_runner_test_run.step);
test_step.dependOn(&b.addRunArtifact(canvas_tests).step);
test_step.dependOn(&b.addRunArtifact(record_store_tests).step);
test_step.dependOn(&file_crash_run.step);
@@ -617,7 +656,9 @@ pub fn build(b: *std.Build) void {
// no build inputs/outputs to hash, so always run it.
scaffold_ide_e2e_run.has_side_effects = true;
const ai_chat_e2e_run = b.addRunArtifact(ts_core_artifacts.ai_chat);
const feed_reader_e2e_run = b.addRunArtifact(ts_core_artifacts.feed_reader);
const services_e2e_run = b.addRunArtifact(ts_core_artifacts.services);
ts_services_e2e_step.dependOn(&feed_reader_e2e_run.step);
ts_services_e2e_step.dependOn(&services_e2e_run.step);
// The same fixture through the in-process carrier (ServicePool over
// the linked service archive): parallel keys, per-key FIFO,
@@ -644,13 +685,15 @@ pub fn build(b: *std.Build) void {
const sidecar_conformance_run = b.addRunArtifact(ts_core_artifacts.sidecar_conformance);
const sidecar_conformance_step = b.step("sidecar-conformance", "Validate corewire-generated mirrors over every fixture's frontend-emitted contract (requires node)");
sidecar_conformance_step.dependOn(&sidecar_conformance_run.step);
// ABI-law suite over a real compiled core: the markup fixture's
// archive driven directly through the C ABI (collect invariant,
// deterministic re-init, channel envelopes, integer classes).
// ABI-law suites over real compiled cores: the broad markup fixture
// plus the focused mixed bare-Model/[Model, Cmd] return regression.
const abi_laws_run = b.addRunArtifact(ts_core_artifacts.external_core_abi_laws);
const abi_laws_step = b.step("test-external-core-abi", "Run the compiled-core ABI-law suite over the markup fixture's archive (requires node and `npm ci` in packages/core)");
const mixed_return_abi_run = b.addRunArtifact(ts_core_artifacts.mixed_return_abi_laws);
const abi_laws_step = b.step("test-external-core-abi", "Run the compiled-core ABI-law suites, including mixed update returns (requires node and `npm ci` in packages/core)");
abi_laws_step.dependOn(&abi_laws_run.step);
abi_laws_step.dependOn(&mixed_return_abi_run.step);
test_step.dependOn(&abi_laws_run.step);
test_step.dependOn(&mixed_return_abi_run.step);
// The cross-execution battery staging: the host-fixture and
// markup batteries (update/snapshot/effects and the markup view
// over genuinely compiled cores) plus, where the target admits
@@ -664,6 +707,14 @@ pub fn build(b: *std.Build) void {
if (ts_core_artifacts.services_pool) |pool_tests| {
cross_e2e_step.dependOn(&b.addInstallArtifact(pool_tests, .{ .dest_dir = e2e_dir }).step);
}
// The mobile execution battery staging: one static library the
// platform toolchain (xcrun clang / NDK clang) links around a tiny
// harness for execution on the simulator or emulator. Registered
// only for a mobile -Dtarget; scripts/mobile-e2e.sh is the driver.
if (ts_core_artifacts.mobile_battery) |battery| {
const mobile_e2e_step = b.step("stage-mobile-e2e", "Install the TS mobile e2e battery archive under <prefix>/e2e (pair with -Dtarget=aarch64-ios-simulator or aarch64-linux-android and -p; see scripts/mobile-e2e.sh)");
mobile_e2e_step.dependOn(&b.addInstallFileWithDir(battery, .{ .custom = "e2e" }, "libts-mobile-e2e.a").step);
}
// The corpus contract artifacts an external core toolchain
// consumes: per fixture, the frontend-emitted contract sidecar
// (after projection), the generated entry module, and the
@@ -683,6 +734,7 @@ pub fn build(b: *std.Build) void {
ts_core_e2e_step.dependOn(&monitor_e2e_run.step);
ts_core_e2e_step.dependOn(&scaffold_ide_e2e_run.step);
ts_core_e2e_step.dependOn(&ai_chat_e2e_run.step);
ts_core_e2e_step.dependOn(&feed_reader_e2e_run.step);
ts_core_e2e_step.dependOn(&services_e2e_run.step);
if (services_pool_e2e_run) |run| ts_core_e2e_step.dependOn(&run.step);
test_step.dependOn(&host_e2e_run.step);
@@ -693,6 +745,7 @@ pub fn build(b: *std.Build) void {
test_step.dependOn(&monitor_e2e_run.step);
test_step.dependOn(&scaffold_ide_e2e_run.step);
test_step.dependOn(&ai_chat_e2e_run.step);
test_step.dependOn(&feed_reader_e2e_run.step);
test_step.dependOn(&services_e2e_run.step);
if (services_pool_e2e_run) |run| test_step.dependOn(&run.step);
test_step.dependOn(&sidecar_conformance_run.step);
@@ -787,10 +840,16 @@ pub fn build(b: *std.Build) void {
.{ .path = "build.zig", .pattern = "if (!app_build.linuxGlibcSpellingHitsDefaultFloor(target)) return;" },
.{ .path = "build/app.zig", .pattern = "scriptcPlatformTriple(b, target),\n // Keep the core and service archives on the same cross compiler" },
.{ .path = "build.zig", .pattern = "app_build.scriptcPlatformTriple(b, target),\n \"--zig-exe\",\n b.graph.zig_exe," },
.{ .path = "build.zig", .pattern = "compile.addArgs(&.{ \"--host-platform\", host_platform, \"--target-platform\", app_build.scriptcPlatformTriple(b, target), \"--zig-exe\", b.graph.zig_exe });\n addScriptcGlibcFloorTeaching(b, target, &compile.step);" },
.{ .path = "build.zig", .pattern = "app_build.scriptcPlatformTriple(b, target),\n \"--zig-exe\",\n b.graph.zig_exe,\n });\n addScriptcGlibcFloorTeaching(b, target, &compile.step);" },
.{ .path = "build.zig", .pattern = "compile.addArgs(&.{ \"--host-platform\", host_platform, \"--target-platform\", app_build.scriptcPlatformTriple(b, target), \"--zig-exe\", b.graph.zig_exe });\n app_build.addScriptcAndroidNdk(b, compile, target);\n addScriptcGlibcFloorTeaching(b, target, &compile.step);" },
.{ .path = "build.zig", .pattern = "app_build.scriptcPlatformTriple(b, target),\n \"--zig-exe\",\n b.graph.zig_exe,\n });\n app_build.addScriptcAndroidNdk(b, compile, target);\n addScriptcGlibcFloorTeaching(b, target, &compile.step);" },
.{ .path = "build.zig", .pattern = "abi_laws_mod.addObjectFile(markup_fixture.archive);\n addScriptcArchiveSystemLibs(abi_laws_mod, target);" },
.{ .path = "packages/core/scripts/run_external_core_compiler.mjs", .pattern = "SCRIPTC_TARGET: args[\"target-platform\"]" },
// Both drivers map the build graph's Zig triple onto the compiler's
// own mobile spellings and thread the NDK like --zig-exe.
.{ .path = "packages/core/scripts/run_external_core_compiler.mjs", .pattern = "SCRIPTC_TARGET: scriptcTarget" },
.{ .path = "packages/core/scripts/run_external_service_compiler.mjs", .pattern = "SCRIPTC_TARGET: scriptcTarget" },
.{ .path = "packages/core/scripts/run_external_core_compiler.mjs", .pattern = "ANDROID_NDK_ROOT: args[\"android-ndk\"]" },
.{ .path = "packages/core/scripts/run_external_service_compiler.mjs", .pattern = "ANDROID_NDK_ROOT: args[\"android-ndk\"]" },
.{ .path = "build/app.zig", .pattern = ".ios => host.os.tag == .macos and target.result.cpu.arch == .aarch64" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-app-test-entry-analysis", "Verify the managed app test step force-analyzes the entry point (UiApp.create's Model-defaults rule must teach at `native test`, not ambush at `native build`)", &.{
.{ .path = "build/app.zig", .pattern = "app_analysis.zig" },
@@ -798,6 +857,31 @@ pub fn build(b: *std.Build) void {
.{ .path = "build/app.zig", .pattern = "test_step.dependOn(&analysis_obj.step);" },
.{ .path = "src/runtime/ui_app.zig", .pattern = "has no default value - give every Model field a default" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-owned-runner-window-placement", "Verify owned example runners preserve explicit origins and distinguish successful state restoration", &.{
.{ .path = "examples/hello/src/runner.zig", .pattern = ".initial_placement = if (@hasField(@TypeOf(window), \"x\") or @hasField(@TypeOf(window), \"y\")) .explicit else .default" },
.{ .path = "examples/hello/src/runner.zig", .pattern = "window.initial_placement = .restored;" },
.{ .path = "examples/hello/src/runner.zig", .pattern = "app_info.main_window.initial_placement = .restored;" },
.{ .path = "examples/capabilities/src/runner.zig", .pattern = "info.main_window.default_frame = manifestShellStartupFrame(info.main_window.default_frame);" },
.{ .path = "examples/capabilities/src/runner.zig", .pattern = "info.main_window.restore_state = manifestShellStartupRestoreState(info.main_window.restore_state);" },
.{ .path = "examples/capabilities/src/runner.zig", .pattern = "info.main_window.restore_policy = manifestShellStartupRestorePolicy(info.main_window.restore_policy);" },
.{ .path = "examples/capabilities/src/runner.zig", .pattern = "info.main_window.initial_placement = manifestShellStartupInitialPlacement(info.main_window.initial_placement);" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-macos-window-placement-contracts", "Verify both macOS hosts use the primary display and restore persisted content frames without titlebar growth", &.{
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "return [NSScreen screens].firstObject ?: [NSScreen mainScreen];" },
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "return [NSScreen screens].firstObject ?: [NSScreen mainScreen];" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "[window frameRectForContentRect:restoredContentFrame]" },
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "[window frameRectForContentRect:restoredContentFrame]" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "[window setFrame:restoredWindowFrame display:NO];" },
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "[window setFrame:restoredWindowFrame display:NO];" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "[window setFrame:NativeSdkCenterFrameOnScreen(window.frame, primaryScreen) display:NO];" },
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "[window setFrame:NativeSdkCenterFrameOnScreen(window.frame, primaryScreen) display:NO];" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "else if (initialPlacement == 1) {\n // Fresh authored dimensions are content size" },
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "else if (initialPlacement == 1) {\n // AppKit adds titlebar chrome" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "[window setFrame:NativeSdkConstrainFrame(window.frame) display:NO];" },
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "[window setFrame:NativeSdkConstrainFrame(window.frame) display:NO];" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "initWithFrame:window.contentView.bounds" },
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "initWithFrame:window.contentView.bounds" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-bridge-view-selector-helpers", "Verify injected view helpers accept string selectors", &.{
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "viewSelectorPayload(options)" },
.{ .path = "src/platform/macos/cef_host.mm", .pattern = "viewSelectorPayload(options)" },
@@ -1432,6 +1516,13 @@ pub fn build(b: *std.Build) void {
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkTextNavigationNeedsRawKeyEvent(event)" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSEventModifierFlagCommand | NSEventModifierFlagOption" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-widget-accessibility-hierarchy", "Verify AppKit preserves retained-widget accessibility parentage", &.{
.{ .path = "src/platform/macos/appkit_host.h", .pattern = "uint64_t parent_id;" },
.{ .path = "src/platform/macos/root.zig", .pattern = ".parent_id = node.parent_id orelse 0" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "element.accessibilityParent = parent;" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "parent.accessibilityChildren = [childrenByParentId objectForKey:parentId];" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "return self.widgetAccessibilityRootElements ?: @[];" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-appearance-bridge", "Verify AppKit reports system light and dark appearance changes", &.{
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "effectiveAppearance" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "accessibilityDisplayShouldReduceMotion" },
@@ -1454,6 +1545,7 @@ pub fn build(b: *std.Build) void {
addTestStep(b, "test-diagnostics", "Run diagnostics module tests", diagnostics_tests);
addTestStep(b, "test-platform-info", "Run platform info module tests", platform_info_tests);
addTestStep(b, "test-json", "Run JSON primitive tests", json_tests);
addTestStep(b, "test-app-runner-window-placement", "Run app-runner window placement decision tests", app_runner_window_placement_tests);
addTestStep(b, "test-canvas", "Run canvas display list tests", canvas_tests);
addTestStep(b, "test-desktop", "Run Native SDK framework tests", desktop_tests);
for (desktop_test_shard_specs, desktop_test_shards) |spec, shard_tests| {
@@ -1687,6 +1779,7 @@ pub fn build(b: *std.Build) void {
"examples/calculator/zig-out/package/test-ios-layout/calculator.xcodeproj/xcshareddata/xcschemes/calculator.xcscheme",
"examples/calculator/zig-out/package/test-ios-layout/Host/uikit_host.m",
"examples/calculator/zig-out/package/test-ios-layout/Host/native_sdk_app.h",
"examples/calculator/zig-out/package/test-ios-layout/Host/apple_image_fit.h",
"examples/calculator/zig-out/package/test-ios-layout/Host/Info.plist",
"examples/calculator/zig-out/package/test-ios-layout/Assets.xcassets/AppIcon.appiconset/AppIcon.png",
"examples/calculator/zig-out/package/test-ios-layout/Assets.xcassets/AppIcon.appiconset/Contents.json",
@@ -1787,13 +1880,20 @@ pub fn build(b: *std.Build) void {
});
addFileContainsCheckStep(b, file_contains_checker, mobile_examples_step, "test-example-mobile-widget-abi", "Verify mobile examples use stable widget ABI lookups", &.{
.{ .path = "examples/ios/NativeSdkIOSExample/native_sdk.h", .pattern = "native_sdk_viewport_state_t" },
.{ .path = "examples/ios/NativeSdkIOSExample/native_sdk.h", .pattern = "NATIVE_SDK_WIDGET_ROLE_RADIOGROUP = 21" },
.{ .path = "examples/ios/NativeSdkIOSExample/native_sdk.h", .pattern = "native_sdk_app_scroll" },
.{ .path = "examples/ios/NativeSdkIOSExample/native_sdk.h", .pattern = "native_sdk_app_set_text_measure" },
.{ .path = "examples/android/app/src/main/cpp/native_sdk.h", .pattern = "NATIVE_SDK_WIDGET_ROLE_RADIOGROUP = 21" },
.{ .path = "examples/android/app/src/main/cpp/native_sdk.h", .pattern = "native_sdk_app_set_text_measure" },
.{ .path = "examples/mobile-canvas/ios/native_sdk_app.h", .pattern = "native_sdk_app_set_text_measure" },
.{ .path = "examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift", .pattern = "native_sdk_app_widget_semantics_by_id" },
.{ .path = "examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift", .pattern = "NATIVE_SDK_WIDGET_ROLE_RADIO" },
.{ .path = "examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift", .pattern = "childrenByParentId[node.parentId, default: []].append(element)" },
.{ .path = "examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift", .pattern = "parent.accessibilityContainerType = .semanticGroup" },
.{ .path = "examples/ios/NativeSdkIOSExample/NativeSdkHostViewController.swift", .pattern = "parent.isAccessibilityElement = false" },
.{ .path = "examples/android/app/src/main/cpp/native_sdk.h", .pattern = "native_sdk_app_widget_semantics_by_id" },
.{ .path = "examples/android/app/src/main/java/dev/native_sdk/examples/android/MainActivity.kt", .pattern = "nativeScroll(nativeApp" },
.{ .path = "examples/android/app/src/main/java/dev/native_sdk/examples/android/MainActivity.kt", .pattern = "WIDGET_ROLE_RADIOGROUP -> \"android.widget.RadioGroup\"" },
.{ .path = "examples/android/app/src/main/java/dev/native_sdk/examples/android/MainActivity.kt", .pattern = "nativeWidgetSemanticsByIdFields" },
.{ .path = "examples/android/app/src/main/cpp/native_sdk_jni.c", .pattern = "native_sdk_app_widget_semantics_by_id" },
.{ .path = "examples/android/app/src/main/cpp/native_sdk_jni.c", .pattern = "native_sdk_app_scroll" },
@@ -2077,6 +2177,52 @@ pub fn build(b: *std.Build) void {
native_shell_smoke_run.step.dependOn(&cli_exe.step);
native_shell_smoke_step.dependOn(&native_shell_smoke_run.step);
const menu_bar_smoke_step = b.step("test-menu-bar-smoke", "Run zero-config TypeScript app-menu automation smoke test");
const menu_bar_smoke_build = managedExampleRun(b, cli_exe, &.{ "build", "-Dplatform=macos", "-Dweb-engine=system", "-Dautomation=true", "-Doptimize=Debug" });
menu_bar_smoke_build.setCwd(b.path("examples/menu-bar"));
const menu_bar_smoke_run = b.addSystemCommand(&.{
"sh", "-c",
\\set -eu
\\cd examples/menu-bar
\\app="zig-out/bin/menu-bar"
\\cli="$1"
\\case "$cli" in /*) ;; *) cli="../../$cli" ;; esac
\\automation_dir=".zig-cache/native-sdk-automation"
\\mkdir -p "$automation_dir"
\\rm -f "$automation_dir/snapshot.txt" "$automation_dir/accessibility.txt" "$automation_dir/windows.txt" "$automation_dir"/command*.txt
\\"$app" > .zig-cache/native-sdk-menu-bar-smoke.log 2>&1 &
\\pid=$!
\\trap 'status=$?; kill "$pid" >/dev/null 2>&1 || true; wait "$pid" >/dev/null 2>&1 || true; if [ "$status" -ne 0 ]; then echo "---- app log (.zig-cache/native-sdk-menu-bar-smoke.log) ----" >&2; cat .zig-cache/native-sdk-menu-bar-smoke.log >&2 2>/dev/null || true; fi' EXIT
\\ready="$("$cli" automate wait 2>&1)"
\\case "$ready" in *"ready=true"*) ;; *) echo "menu-bar automation snapshot was not ready" >&2; exit 1 ;; esac
\\before="$(cat "$automation_dir/snapshot.txt" 2>/dev/null || true)"
\\case "$before" in *'command id="player.next" title="Next Track" enabled=true checked=false'*) ;; *) echo "app.zon command catalog was not loaded by the zero-config runner" >&2; exit 1 ;; esac
\\case "$before" in *'app-menu title="Player" items=6'*) ;; *) echo "app.zon Player menu was not loaded by the zero-config runner" >&2; exit 1 ;; esac
\\case "$before" in *'app-menu-item label="Next Track" command="player.next" enabled=true checked=false key="n" modifiers=(primary=true,command=false,control=false,option=false,shift=false)'*) ;; *) echo "app.zon player.next menu item was not loaded by the zero-config runner" >&2; exit 1 ;; esac
\\case "$before" in
\\ *'Ambient Coast'*) expected='Night Drive' ;;
\\ *'Night Drive'*) expected='Paper Planes' ;;
\\ *'Paper Planes'*) expected='Ambient Coast' ;;
\\ *) echo "menu-bar snapshot did not expose the current TypeScript model track" >&2; exit 1 ;;
\\esac
\\"$cli" automate menu-command player.next >/dev/null 2>&1
\\attempts=0
\\while [ "$attempts" -lt 50 ]; do
\\ snapshot="$(cat "$automation_dir/snapshot.txt" 2>/dev/null || true)"
\\ case "$snapshot" in *"$expected"*) break ;; esac
\\ attempts=$((attempts + 1))
\\ sleep 0.1
\\done
\\case "$snapshot" in *"$expected"*) ;; *) echo "app.zon menu command did not reach the zero-config TypeScript commandMsg mapper" >&2; exit 1 ;; esac
\\echo "menu-bar smoke ok"
,
"sh",
});
menu_bar_smoke_run.addFileArg(cli_exe.getEmittedBin());
menu_bar_smoke_run.step.dependOn(&menu_bar_smoke_build.step);
menu_bar_smoke_run.step.dependOn(&cli_exe.step);
menu_bar_smoke_step.dependOn(&menu_bar_smoke_run.step);
const gpu_surface_smoke_step = b.step("test-gpu-surface-smoke", "Run macOS GPU surface automation smoke test");
// The GPU smoke apps are managed examples (no build.zig of their own),
// so their binaries come from the CLI verb. -Doptimize=Debug keeps the
@@ -2567,7 +2713,7 @@ pub fn build(b: *std.Build) void {
\\case "$ready_snapshot" in *'view @w1/components-canvas kind=gpu_surface'*'gpu_nonblank=true'*'canvas_frame_gpu_packet_representable=true'*) ;; *) echo "component gallery GPU surface was not ready" >&2; exit 1 ;; esac
\\case "$ready_snapshot" in *'view @w1/main kind=webview'*) echo "component gallery created an implicit WebView" >&2; exit 1 ;; *) ;; esac
\\"$cli" automate assert 'role=tree name="Components"' 'role=treeitem name="Components".*state=\[expanded\]' 'role=treeitem name="Accordion".*state=\[selected\]' 'role=group name="Details".*state=\[selected,expanded\]' 'name="Accordion details are visible. The model owns this expanded state."'
\\"$cli" automate assert 'role=group name="Theme"' 'role=button name="Default".*state=\[selected\]' 'role=button name="Geist"'
\\"$cli" automate assert 'role=group name="Theme pack"' 'role=group name="Color scheme"' 'role=group name="Theme accent"' 'role=button name="Default".*state=\[selected\]' 'role=button name="Geist"' 'role=button name="System".*state=\[selected\]' 'role=button name="Pink"' 'role=button name="Teal"'
\\"$cli" automate screenshot components-canvas >/dev/null 2>&1
\\cp "$automation_dir/screenshot-components-canvas.png" "$automation_dir/screenshot-components-house.png"
\\rm -f "$automation_dir/screenshot-components-canvas.png"
@@ -3018,6 +3164,10 @@ const TsCoreE2eArtifacts = struct {
/// paths, and builds keep working with node_modules deleted.
scaffold_ide: *std.Build.Step.Compile,
ai_chat: *std.Build.Step.Compile,
/// The complete services loop over examples/service-feed-reader: a real
/// loopback Cmd.fetch, the generated typed client into a real service
/// child, the shipping markup, and record→replay without either.
feed_reader: *std.Build.Step.Compile,
/// The phase-1 service seam: a real compiled core plus a real plain-scriptc
/// service executable driven through the out-of-process carrier.
services: *std.Build.Step.Compile,
@@ -3027,6 +3177,13 @@ const TsCoreE2eArtifacts = struct {
/// cancellation/deadlines, trap isolation, streaming, replay). Null when
/// the target cannot carry the archive (cross or non-desktop builds).
services_pool: ?*std.Build.Step.Compile,
/// The mobile execution battery: the pool fixture's core and service
/// archives linked into one static library exporting `nsme_run`, so
/// the platform toolchain links a runnable harness for the simulator
/// or emulator (scripts/mobile-e2e.sh). Present only for a mobile
/// -Dtarget; the Android archive is flattened to plain objects (Zig's
/// ELF static-library emission nests archive inputs).
mobile_battery: ?std.Build.LazyPath,
/// The service-host carrier benchmark: a bytes-echo service compiled
/// through the same lane, driven directly through the production
/// carrier bindings of BOTH carriers (cold start, round-trip latency,
@@ -3042,6 +3199,9 @@ const TsCoreE2eArtifacts = struct {
/// (boot fence, collect invariant, deterministic re-init, channel
/// envelopes, integer classes).
external_core_abi_laws: *std.Build.Step.Compile,
/// The mixed-return ABI regression: a real compiled core whose update
/// returns both bare Model and [Model, Cmd] through the generated facade.
mixed_return_abi_laws: *std.Build.Step.Compile,
/// Per-fixture contract artifacts for an external core toolchain:
/// the effective contract sidecar and its TypeScript facade/profile
/// projections, installed by the stage-core-contracts step.
@@ -3136,6 +3296,13 @@ fn tsCoreE2eArtifact(
.name = "markup_core",
});
const markup_fixture_mod = markup_fixture.module;
const mixed_return_src = b.addWriteFiles();
_ = mixed_return_src.addCopyFile(b.path("tests/ts-core/mixed_return_fixture.ts"), "mixed_return_fixture.ts");
const mixed_return_fixture = externalCoreFixtureModule(b, target, optimize, node, corewire_exe, .{
.entry = "tests/ts-core/mixed_return_fixture.ts",
.src_dir = mixed_return_src.getDirectory(),
.name = "mixed_return_core",
});
const e2e_mod = module(b, target, optimize, "tests/ts-core/host_e2e_tests.zig");
e2e_mod.addImport("native_sdk", desktop_mod);
@@ -3200,6 +3367,8 @@ fn tsCoreE2eArtifact(
const monitor_stage = b.addWriteFiles();
const monitor_root = monitor_stage.addCopyFile(b.path("tests/ts-core/system_monitor_e2e_tests.zig"), "system_monitor_e2e_tests.zig");
_ = monitor_stage.addCopyFile(b.path("examples/system-monitor-ts/src/app.native"), "app.native");
_ = monitor_stage.addCopyFile(b.path("examples/system-monitor-ts/src/windows/settings.native"), "settings.native");
_ = monitor_stage.addCopyFile(b.path("examples/system-monitor-ts/src/windows/components/sampling.native"), "components/sampling.native");
_ = monitor_stage.addCopyFile(b.path("examples/system-monitor/src/fixtures/sysctl.txt"), "fixtures/sysctl.txt");
_ = monitor_stage.addCopyFile(b.path("examples/system-monitor/src/fixtures/ps.txt"), "fixtures/ps.txt");
_ = monitor_stage.addCopyFile(b.path("examples/system-monitor/src/fixtures/vm_stat.txt"), "fixtures/vm_stat.txt");
@@ -3237,6 +3406,42 @@ fn tsCoreE2eArtifact(
ai_chat_mod.addImport("native_sdk", desktop_mod);
ai_chat_mod.addImport("ts_ai_chat_core", ai_chat_core_mod);
// The service-feed-reader example's core, service child, and shipping
// markup, tested as one app: a real buffered Cmd.fetch against a
// loopback fixture, the generated typed client into the real child,
// and record→replay with neither the service nor the network present.
const feed_reader_fixture = externalCoreFixtureModule(b, target, optimize, node, corewire_exe, .{
.entry = "examples/service-feed-reader/src/core.ts",
.src_dir = b.path("examples/service-feed-reader/src"),
.name = "feed_reader_core",
.emit_services = true,
});
const feed_reader_service = externalServiceFixture(
b,
target,
optimize,
node,
corewire_exe,
b.path("examples/service-feed-reader/src"),
feed_reader_fixture.services_contract.?,
"feed_reader_services",
);
const feed_reader_stage = b.addWriteFiles();
const feed_reader_root = feed_reader_stage.addCopyFile(b.path("tests/ts-services/feed_reader_e2e_tests.zig"), "feed_reader_e2e_tests.zig");
_ = feed_reader_stage.addCopyFile(b.path("examples/service-feed-reader/src/app.native"), "app.native");
_ = feed_reader_stage.addCopyFile(b.path("examples/service-feed-reader/fixtures/feed.xml"), "fixture_feed.xml");
const feed_reader_mod = b.createModule(.{
.root_source_file = feed_reader_root,
.target = target,
.optimize = optimize,
});
feed_reader_mod.addImport("native_sdk", desktop_mod);
feed_reader_mod.addImport("ts_feed_reader_core", feed_reader_fixture.module);
feed_reader_mod.addImport("ts_feed_reader_registry", feed_reader_service.registry);
const feed_reader_options = b.addOptions();
feed_reader_options.addOptionPath("service_executable", feed_reader_service.executable);
feed_reader_mod.addOptions("ts_feed_reader_options", feed_reader_options);
// Phase-1 TypeScript services, end to end: the frontend emits BOTH
// sidecars from one checked two-class program; corewire derives the
// registry/host only from the service sidecar; plain scriptc compiles the
@@ -3283,6 +3488,46 @@ fn tsCoreE2eArtifact(
break :pool pool_mod;
} else null;
// The mobile execution battery: a static library over the same pool
// fixture, linked into a runnable harness by the platform toolchain —
// the same link pattern embedding apps use for the embed library. The
// Android objects are PIC like the embed library's (the harness there
// is a bionic executable; a future .so embedding needs it regardless).
const battery_target_is_mobile = target.result.os.tag == .ios or target.result.abi.isAndroid();
const mobile_battery: ?std.Build.LazyPath = if (battery_target_is_mobile and service_host_fixture.archive != null) battery: {
const battery_mod = b.createModule(.{
.root_source_file = b.path("tests/ts-services/mobile_e2e_battery.zig"),
.target = target,
.optimize = optimize,
.pic = if (target.result.abi.isAndroid()) true else null,
});
battery_mod.addImport("native_sdk", desktop_mod);
battery_mod.addImport("ts_services_core", services_fixture.module);
battery_mod.addImport("ts_services_registry", service_host_fixture.registry);
battery_mod.link_libc = true;
battery_mod.addObjectFile(service_host_fixture.archive.?);
const battery_lib = b.addLibrary(.{
.linkage = .static,
.name = "ts-mobile-e2e",
.root_module = battery_mod,
.use_llvm = @import("build/app.zig").useLlvmWorkaround(target),
});
if (target.result.abi.isAndroid()) {
// Flatten the nested TypeScript archives (Zig's ELF static-lib
// emission stores .a inputs as members) so the NDK link consumes
// plain objects — the same normalization the app lane applies.
const merge = b.addSystemCommand(&.{node});
merge.addFileArg(b.path("packages/core/scripts/merge_static_archives.mjs"));
merge.addArgs(&.{ "--zig", b.graph.zig_exe, "--format", "gnu" });
merge.addArg("--out");
const merged = merge.addOutputFileArg("libts-mobile-e2e.a");
merge.addArg("--in");
merge.addFileArg(battery_lib.getEmittedBin());
break :battery merged;
}
break :battery battery_lib.getEmittedBin();
} else null;
// Service-host carrier benchmark: the same production service lane
// (frontend contract -> corewire host/registry -> exact-pinned
// plain-scriptc executable) over a bytes-echo operation that returns
@@ -3335,6 +3580,9 @@ fn tsCoreE2eArtifact(
// against (the same module the generated shims stage).
conformance_mod.addImport("corewire_rt", module(b, target, optimize, "tools/corewire/shim_rt.zig"));
conformance_mod.addImport("shim_markup_core", sidecarShimModule(b, target, optimize, corewire_exe, b.path("tests/sidecar/markup_fixture.contract.json")));
// Compile-cost guard: this generated mirror carries 160 realistically
// named Msg arms and must need no quota setting in app or test code.
conformance_mod.addImport("shim_wide_core", sidecarShimModule(b, target, optimize, corewire_exe, b.path("tests/sidecar/wide_msg_fixture.contract.json")));
// The integer-class fixture: a hand-written sidecar attesting mixed
// i64/u64 slot classes, so the suite drives boundary and full-range
// integer values through a generated mirror's decode paths.
@@ -3395,6 +3643,9 @@ fn tsCoreE2eArtifact(
abi_laws_mod.addObjectFile(markup_fixture.archive);
addScriptcArchiveSystemLibs(abi_laws_mod, target);
const mixed_return_abi_mod = module(b, target, optimize, "tests/sidecar/mixed_return_abi_tests.zig");
mixed_return_abi_mod.addImport("mixed_return_core", mixed_return_fixture.module);
return .{
.host = filteredTestArtifact(b, e2e_mod, "ts-core-e2e-tests", &.{}),
.persist = filteredTestArtifact(b, persist_mod, "ts-persist-e2e-tests", &.{}),
@@ -3404,11 +3655,14 @@ fn tsCoreE2eArtifact(
.system_monitor = filteredTestArtifact(b, monitor_mod, "ts-system-monitor-e2e-tests", &.{}),
.scaffold_ide = filteredTestArtifact(b, scaffold_ide_mod, "ts-scaffold-ide-e2e-tests", &.{}),
.ai_chat = filteredTestArtifact(b, ai_chat_mod, "ts-ai-chat-e2e-tests", &.{}),
.feed_reader = filteredTestArtifact(b, feed_reader_mod, "ts-feed-reader-e2e-tests", &.{}),
.services = filteredTestArtifact(b, services_e2e_mod, "ts-services-e2e-tests", &.{}),
.services_pool = if (services_pool_mod) |pool_mod| filteredTestArtifact(b, pool_mod, "ts-services-pool-e2e-tests", &.{}) else null,
.mobile_battery = mobile_battery,
.service_host_bench = service_bench_exe,
.sidecar_conformance = filteredTestArtifact(b, conformance_mod, "sidecar-conformance-tests", &.{}),
.external_core_abi_laws = filteredTestArtifact(b, abi_laws_mod, "external-core-abi-tests", &.{}),
.mixed_return_abi_laws = filteredTestArtifact(b, mixed_return_abi_mod, "mixed-return-abi-tests", &.{}),
.core_contracts = core_contracts.toOwnedSlice(b.allocator) catch @panic("OOM"),
};
}
@@ -3511,15 +3765,24 @@ fn externalServiceFixture(
compile.addFileArg(b.path("packages/core/package.json"));
compile.addArg("--contract");
compile.addFileArg(contract);
compile.addArg("--out-exe");
const suffix = if (target.result.os.tag == .windows) ".exe" else "";
const executable = compile.addOutputFileArg(b.fmt("{s}{s}", .{ name, suffix }));
// Mobile targets are archive-only (no child process exists there): the
// driver refuses --out-exe, so the child-carrier batteries get a stub
// path they never execute for those targets.
const mobile_target = target.result.os.tag == .ios or target.result.abi.isAndroid();
const executable: std.Build.LazyPath = if (mobile_target)
b.addWriteFiles().add(b.fmt("{s}-unavailable-on-mobile", .{name}), "")
else exe: {
compile.addArg("--out-exe");
const suffix = if (target.result.os.tag == .windows) ".exe" else "";
break :exe compile.addOutputFileArg(b.fmt("{s}{s}", .{ name, suffix }));
};
const archive: ?std.Build.LazyPath = if (emit_archive) archive: {
compile.addArg("--out-archive");
break :archive compile.addOutputFileArg(b.fmt("lib{s}.a", .{name}));
} else null;
const host_platform = b.fmt("{t}-{t}-{t}", .{ b.graph.host.result.cpu.arch, b.graph.host.result.os.tag, b.graph.host.result.abi });
compile.addArgs(&.{ "--host-platform", host_platform, "--target-platform", app_build.scriptcPlatformTriple(b, target), "--zig-exe", b.graph.zig_exe });
app_build.addScriptcAndroidNdk(b, compile, target);
addScriptcGlibcFloorTeaching(b, target, &compile.step);
if (b.graph.environ_map.get("NATIVE_SDK_CORE_COMPILER")) |override| {
compile.addArgs(&.{ "--compiler", override });
@@ -3764,6 +4027,7 @@ fn externalCoreFixtureModule(
"--zig-exe",
b.graph.zig_exe,
});
app_build.addScriptcAndroidNdk(b, compile, target);
addScriptcGlibcFloorTeaching(b, target, &compile.step);
if (b.graph.environ_map.get("NATIVE_SDK_CORE_COMPILER")) |override| {
// The development override: point at any toolchain command; the
+351 -33
View File
@@ -75,6 +75,156 @@ fn appFileExists(b: *std.Build, app_root: []const u8, sub_path: []const u8) bool
return true;
}
const TsWindowView = struct {
label: []const u8,
source_path: []const u8,
staged_path: []const u8,
};
const TsWindowSource = struct {
set_path: []const u8,
source_path: []const u8,
staged_path: []const u8,
};
const TsWindowViews = struct {
views: []const TsWindowView,
sources: []const TsWindowSource,
};
/// Default TypeScript secondary-window views are statically discovered under
/// `src/windows/`: `settings.native` serves descriptor label `settings`.
/// Direct files form the generated launcher's closed, comptime-compiled view
/// set while nested `.native` files are the shared import source set and
/// `windows(model)` owns dynamic liveness.
fn collectTsWindowViews(b: *std.Build, app_root: []const u8) TsWindowViews {
const windows_path = appPath(b, app_root, "src/windows");
var dir = b.build_root.handle.openDir(b.graph.io, windows_path, .{ .iterate = true }) catch return .{ .views = &.{}, .sources = &.{} };
defer dir.close(b.graph.io);
var walker = dir.walk(b.allocator) catch return .{ .views = &.{}, .sources = &.{} };
defer walker.deinit();
var views: std.ArrayList(TsWindowView) = .empty;
var sources: std.ArrayList(TsWindowSource) = .empty;
while (walker.next(b.graph.io) catch null) |entry| {
if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".native")) continue;
const normalized_path = b.dupe(entry.path);
for (normalized_path) |*char| {
if (char.* == '\\') char.* = '/';
}
sources.append(b.allocator, .{
.set_path = normalized_path,
.source_path = b.fmt("src/windows/{s}", .{normalized_path}),
.staged_path = b.fmt("windows/{s}", .{normalized_path}),
}) catch @panic("OOM");
// Direct children are label-addressed window roots. Nested files
// are component sources those roots may import transitively.
if (std.mem.indexOfScalar(u8, entry.path, '/') != null or std.mem.indexOfScalar(u8, entry.path, '\\') != null) continue;
const label = entry.path[0 .. entry.path.len - ".native".len];
if (label.len == 0 or label.len > 64 or std.mem.eql(u8, label, ".") or std.mem.eql(u8, label, "..")) {
@panic("\ninvalid TypeScript secondary-window view filename: use src/windows/<label>.native with a non-empty label of at most 64 bytes\n");
}
for (label) |ch| {
if (ch == 0 or ch == '/' or ch == '\\') {
@panic("\ninvalid TypeScript secondary-window view filename: the filename stem must be a valid window label\n");
}
}
views.append(b.allocator, .{
.label = b.dupe(label),
.source_path = b.fmt("src/windows/{s}", .{entry.path}),
.staged_path = b.fmt("windows/{s}", .{entry.path}),
}) catch @panic("OOM");
}
const less = struct {
fn than(_: void, a: TsWindowView, z: TsWindowView) bool {
return std.mem.order(u8, a.label, z.label) == .lt;
}
}.than;
std.mem.sort(TsWindowView, views.items, {}, less);
const source_less = struct {
fn than(_: void, a: TsWindowSource, z: TsWindowSource) bool {
return std.mem.order(u8, a.set_path, z.set_path) == .lt;
}
}.than;
std.mem.sort(TsWindowSource, sources.items, {}, source_less);
return .{ .views = views.items, .sources = sources.items };
}
fn tsWindowRegistrySource(b: *std.Build, registry: TsWindowViews) []const u8 {
const views = registry.views;
var out: std.ArrayList(u8) = .empty;
out.appendSlice(b.allocator,
\\//! Generated by build/app.zig from src/windows/<label>.native.
\\const std = @import("std");
\\const native_sdk = @import("native_sdk");
\\const core = @import("core.zig");
\\const canvas = native_sdk.canvas;
\\const Adapter = native_sdk.TsUiApp(core);
\\const App = Adapter.App;
\\
) catch @panic("OOM");
if (registry.sources.len > 0) {
out.appendSlice(b.allocator,
\\const window_sources = [_]canvas.ui_markup.SourceFile{
\\
) catch @panic("OOM");
for (registry.sources) |source| {
const line = std.fmt.allocPrint(
b.allocator,
" .{{ .path = \"{f}\", .source = @embedFile(\"{f}\") }},\n",
.{ std.zig.fmtString(source.set_path), std.zig.fmtString(source.staged_path) },
) catch @panic("OOM");
out.appendSlice(b.allocator, line) catch @panic("OOM");
}
out.appendSlice(b.allocator, "};\n") catch @panic("OOM");
}
for (views, 0..) |view, index| {
const line = std.fmt.allocPrint(
b.allocator,
"const WindowView{d} = canvas.CompiledMarkupImports(core.Model, core.Msg, \"{f}\", &window_sources);\n",
.{ index, std.zig.fmtString(std.fs.path.basename(view.staged_path)) },
) catch @panic("OOM");
out.appendSlice(b.allocator, line) catch @panic("OOM");
}
if (views.len == 0) {
out.appendSlice(b.allocator,
\\pub fn build(_: *App.Ui, _: *const core.Model, _: []const u8) App.Ui.Node {
\\ @compileError("this TypeScript core exports windows(model), but the app has no src/windows/<label>.native views");
\\}
\\pub const fragments = [_]canvas.MarkupFragment{};
\\
) catch @panic("OOM");
return out.items;
}
out.appendSlice(b.allocator,
\\pub fn build(ui: *App.Ui, model: *const core.Model, label: []const u8) App.Ui.Node {
\\
) catch @panic("OOM");
for (views, 0..) |view, index| {
const line = std.fmt.allocPrint(
b.allocator,
" if (std.mem.eql(u8, label, \"{f}\")) return WindowView{d}.build(ui, model);\n",
.{ std.zig.fmtString(view.label), index },
) catch @panic("OOM");
out.appendSlice(b.allocator, line) catch @panic("OOM");
}
out.appendSlice(b.allocator,
\\ @panic("windows(model) declared a label with no src/windows/<label>.native view");
\\}
\\pub const fragments = [_]canvas.MarkupFragment{
\\
) catch @panic("OOM");
for (views, 0..) |view, index| {
const line = std.fmt.allocPrint(
b.allocator,
" WindowView{d}.fragment(\"{f}\"),\n",
.{ index, std.zig.fmtString(view.source_path) },
) catch @panic("OOM");
out.appendSlice(b.allocator, line) catch @panic("OOM");
}
out.appendSlice(b.allocator, "};\n") catch @panic("OOM");
return out.items;
}
/// How a TypeScript core compiles: through the external core compiler,
/// always. The frontend checks the core and emits its contract sidecar,
/// corewire projects the compile entry and profile, the exact-pinned
@@ -96,6 +246,10 @@ const core_compiler_teaching =
/// and test modules.
const TsCoreStage = struct {
main_root: std.Build.LazyPath,
/// The staged mobile wiring (ts_core_mobile.zig beside the same
/// mirror/markup/registry files): the embed static library's `app`
/// module roots here on iOS/Android targets.
mobile_root: std.Build.LazyPath,
/// The compiled-core archive: the app module links it (with libc,
/// for the toolchain's runtime) beside the staged mirror.
archive: std.Build.LazyPath,
@@ -125,7 +279,11 @@ const ServiceCarrier = enum { none, child, in_process };
/// this host/target pairing. Native desktop targets use their host toolchain,
/// including native Windows/MSVC. Cross-Windows builds use Zig's bundled GNU
/// sysroot; an MSVC cross target has no CRT headers or libraries to compile
/// the ScriptC runtime against.
/// the ScriptC runtime against. Mobile targets are aarch64 only and
/// archive-only (library mode — the app links the archive; no standalone
/// executable exists there): iOS device/simulator archives build on a macOS
/// host against the selected Apple SDK, Android archives build on any
/// desktop host against a discovered NDK sysroot.
pub fn scriptcCompileSupported(host: std.Target, target: std.Build.ResolvedTarget) bool {
const desktop_host = switch (host.os.tag) {
.macos, .linux, .windows => true,
@@ -134,9 +292,10 @@ pub fn scriptcCompileSupported(host: std.Target, target: std.Build.ResolvedTarge
if (!desktop_host) return false;
const cross = scriptcTargetIsCross(host, target);
return switch (target.result.os.tag) {
.linux => true,
.linux => if (target.result.abi.isAndroid()) target.result.cpu.arch == .aarch64 else true,
.windows => !cross or target.result.abi == .gnu,
.macos => host.os.tag == .macos,
.ios => host.os.tag == .macos and target.result.cpu.arch == .aarch64,
else => false,
};
}
@@ -144,16 +303,18 @@ pub fn scriptcCompileSupported(host: std.Target, target: std.Build.ResolvedTarge
/// Whether the pinned service compiler can build the in-process carrier's
/// archive (runtime-localized, thread-instanced) on this build host for
/// this target. Its object localizers are deliberately architecture-aware:
/// native Linux uses host binutils, cross-ELF accepts x86_64/aarch64, COFF
/// accepts x86_64 (GNU when cross-compiled; the host ABI when native), and
/// Mach-O needs a macOS host (Apple linking rides the host toolchain's SDK).
/// native Linux uses host binutils, cross-ELF accepts x86_64/aarch64
/// (Android's aarch64 archives ride this lane), COFF accepts x86_64 (GNU
/// when cross-compiled; the host ABI when native), and Mach-O — macOS, iOS
/// device, and iOS simulator — needs a macOS host (Apple linking rides the
/// host toolchain's SDK).
pub fn serviceArchiveSupported(host: std.Target, target: std.Build.ResolvedTarget) bool {
if (!scriptcCompileSupported(host, target)) return false;
const cross = scriptcTargetIsCross(host, target);
return switch (target.result.os.tag) {
.linux => !cross or target.result.cpu.arch == .x86_64 or target.result.cpu.arch == .aarch64,
.windows => target.result.cpu.arch == .x86_64,
.macos => host.os.tag == .macos,
.macos, .ios => host.os.tag == .macos,
else => false,
};
}
@@ -216,9 +377,16 @@ pub fn panicUnsupportedScriptcTarget(b: *std.Build, host: std.Target, target: st
.{ target.result.cpu.arch, target.result.cpu.arch },
));
}
if (target.result.os.tag == .ios and host.os.tag != .macos) {
@panic(
"\nTypeScript iOS builds run on a macOS build host only: the Apple SDK sysroot" ++
" and Mach-O symbol localization live there.\nBuild iOS apps on a Mac.\n",
);
}
@panic(
"\nTypeScript desktop builds support native host targets, Linux and Windows GNU" ++
" cross targets from macOS/Linux/Windows, and macOS targets from macOS." ++
"\nTypeScript builds support native host targets, Linux and Windows GNU cross" ++
" targets from macOS/Linux/Windows, macOS targets from macOS, and the mobile" ++
" targets aarch64 iOS/iOS-simulator (from macOS) and aarch64 Android." ++
"\nChoose a supported target/host pairing.\n",
);
}
@@ -232,13 +400,27 @@ fn resolveServiceCarrier(
if (!has_services) return .none;
const host = b.graph.host.result;
const supported = serviceArchiveSupported(host, target);
// Mobile has no child processes, so the in-process pool is the only
// carrier there: auto resolves to it, and an explicit "child" is a
// stated impossibility, taught rather than quietly rewritten.
if (target.result.os.tag == .ios or target.result.abi.isAndroid()) {
return switch (choice) {
.auto, .in_process => if (supported) .in_process else panicUnsupportedScriptcTarget(b, host, target),
.child => @panic(
"\nservice_carrier = \"child\" is unavailable on mobile targets: iOS and Android" ++
" apps cannot spawn a sibling service process, so src/services operations run" ++
" on the in-process pool there.\nUse \"auto\" or \"in_process\" (desktop" ++
" builds of the same app keep the child carrier under auto).\n",
),
};
}
const carrier: ServiceCarrier = switch (choice) {
.auto, .child => .child,
.in_process => if (supported) .in_process else @panic(
"\nservice_carrier = \"in_process\" requires a target the pinned service compiler" ++
" can produce a runtime-localized archive for: native Linux, cross-Linux" ++
" x86_64/aarch64, native Windows x86_64, cross-Windows x86_64 GNU," ++
" or macOS from a macOS build host." ++
" macOS from a macOS build host, or a mobile target." ++
"\nUse \"child\" (or drop the setting — auto selects the child carrier)" ++
" for this target.\n",
),
@@ -574,6 +756,7 @@ fn tsCoreStage(
service_pool_workers: ?u8,
) TsCoreStage {
const node = tsCorePreflight(b, dep, app_root);
const window_views = collectTsWindowViews(b, app_root);
const has_services = appHasServiceFiles(b, app_root);
if (!scriptcCompileSupported(b.graph.host.result, target)) {
panicUnsupportedScriptcTarget(b, b.graph.host.result, target);
@@ -659,6 +842,10 @@ fn tsCoreStage(
if (relational_capability) check.addArgs(&.{ "--capability", "sqlite" });
if (credentials_capability) check.addArgs(&.{ "--capability", "credentials" });
if (credentials_permission) check.addArgs(&.{ "--permission", "credentials" });
// Activate the cross-tier registry check even when discovery found no
// roots: the empty set must reject a core that declares a window.
check.addArg("--window-views");
for (window_views.views) |view| check.addArgs(&.{ "--window-view", view.label });
if (relational_capability) {
check.addArg("--sdk-core");
check.addFileArg(checked_sdk_core);
@@ -717,7 +904,7 @@ fn tsCoreStage(
// Ordinary service TypeScript is staged without core-subset rewrites.
// The one service-boundary lowering turns NS1067's `{ kind, message }`
// throw into the tagged Error shape scriptc 0.0.29 can catch from an
// throw into the tagged Error shape the pinned compiler can catch from an
// imported op; no deterministic profile fences participate here.
const service_stage_run = b.addSystemCommand(&.{node});
service_stage_run.addFileArg(dep.path("packages/core/scripts/stage_external_services.mjs"));
@@ -770,6 +957,7 @@ fn tsCoreStage(
"--zig-exe",
b.graph.zig_exe,
});
addScriptcAndroidNdk(b, service_compile, target);
if (b.graph.environ_map.get("NATIVE_SDK_CORE_COMPILER")) |override| {
service_compile.addArgs(&.{ "--compiler", override });
} else {
@@ -826,6 +1014,7 @@ fn tsCoreStage(
"--zig-exe",
b.graph.zig_exe,
});
addScriptcAndroidNdk(b, compile, target);
if (b.graph.environ_map.get("NATIVE_SDK_CORE_COMPILER")) |override| {
// The development override: point at any toolchain command; the
// driver still refuses a release other than the SDK's pin.
@@ -863,9 +1052,18 @@ fn tsCoreStage(
, .{ service_carrier, service_pool_workers }));
_ = staged.addCopyFile(migrations_zig, "migrations.zig");
_ = staged.addCopyFile(b.path(appPath(b, app_root, "src/app.native")), "app.native");
for (window_views.sources) |source| {
_ = staged.addCopyFile(b.path(appPath(b, app_root, source.source_path)), source.staged_path);
}
_ = staged.add("window_views.zig", tsWindowRegistrySource(b, window_views));
const main_root = staged.addCopyFile(dep.path("src/app_runner/ts_core_main.zig"), "main.zig");
// The mobile wiring stages beside the desktop entry: same mirror, same
// registry, same carrier constant — only the shell differs (the embed
// host's AppDef contract instead of a process `main`).
const mobile_root = staged.addCopyFile(dep.path("src/app_runner/ts_core_mobile.zig"), "mobile.zig");
return .{
.main_root = main_root,
.mobile_root = mobile_root,
.archive = archive,
.service_exe = service_exe,
.service_archive = service_archive,
@@ -1056,6 +1254,29 @@ pub const MobileLibOptions = struct {
filesystem_permission: bool = false,
/// Stable app identity used as the Keychain/Keystore service namespace.
credentials_service: []const u8 = "dev.native_sdk.app",
/// Frozen registered-image pixel budget. Standard app builds infer it
/// from app.zon; low-level embedders default to the SDK's 1 MiB tier.
max_image_pixel_bytes: usize = 1024 * 1024,
/// A TypeScript core's staged mobile wiring: set by `addAppArtifacts`
/// when the tree carries src/core.ts. The `app` module roots at the
/// staged mobile entry instead of `main`, and the compiled core (and
/// in-process service) archives merge into the embed static library —
/// the host tiers keep linking exactly one archive.
ts_core: ?MobileTsCore = null,
};
/// The TypeScript pieces a mobile embed library consumes (see
/// `MobileLibOptions.ts_core`).
pub const MobileTsCore = struct {
/// The staged mobile wiring (mobile.zig beside the generated mirror).
main_root: std.Build.LazyPath,
/// The compiled-core archive; merged into the embed library.
archive: std.Build.LazyPath,
/// The in-process service archive, when src/services exists.
service_archive: ?std.Build.LazyPath,
/// The app.zon module the mobile wiring reads scene chrome, identity,
/// and theme from (`app_manifest_zon`).
manifest_mod: *std.Build.Module,
};
/// Mobile counterpart of `addApp`: produce the embed static library
@@ -1100,15 +1321,39 @@ fn addMobileLibWithTarget(b: *std.Build, dep: *std.Build.Dependency, target: std
mobile_options.addOption(bool, "credentials_permission", options.credentials_permission);
mobile_options.addOption(bool, "filesystem_permission", options.filesystem_permission);
mobile_options.addOption([]const u8, "credentials_service", options.credentials_service);
mobile_options.addOption(usize, "max_image_pixel_bytes", options.max_image_pixel_bytes);
exports_mod.addImport("mobile_build_options", mobile_options.createModule());
const migration_path = options.relational_migrations orelse dep.path("src/app_runner/no_migrations.zig");
const migration_mod = b.createModule(.{ .root_source_file = migration_path, .target = target, .optimize = optimize });
migration_mod.addImport("native_sdk", native_sdk_mod);
exports_mod.addImport("relational_migrations", migration_mod);
const app_mod = localModule(b, target, optimize, options.main);
// A TypeScript core's app module roots at the staged mobile wiring;
// a Zig core's at the app's own mobile entry. Either way the embed
// host sees the same AppDef contract (Model/Msg/initModel/
// mobileOptions).
const app_mod = if (options.ts_core) |ts| ts_app: {
const mod = b.createModule(.{
.root_source_file = ts.main_root,
.target = target,
.optimize = optimize,
});
mod.addImport("app_manifest_zon", ts.manifest_mod);
break :ts_app mod;
} else localModule(b, target, optimize, options.main);
app_mod.addImport("native_sdk", native_sdk_mod);
exports_mod.addImport("app", app_mod);
}
if (options.ts_core) |ts| {
// The compiled TypeScript archives merge into the embed static
// library (Zig's static-lib emission bundles archive inputs), so
// the iOS/Android host tiers keep linking the one archive they
// already stage. The toolchain's runtime needs libc; the host
// link supplies it (plus -lm/-ldl on Android, which the Android
// host link already passes).
exports_mod.link_libc = true;
exports_mod.addObjectFile(ts.archive);
if (ts.service_archive) |service_archive| exports_mod.addObjectFile(service_archive);
}
if (options.store_capability or options.relational_capability) {
exports_mod.addIncludePath(dep.path("third_party/sqlite"));
exports_mod.addCSourceFile(.{
@@ -1131,10 +1376,39 @@ fn addMobileLibWithTarget(b: *std.Build, dep: *std.Build.Dependency, target: std
// Intel simulators). Force LLVM there; Release already uses it.
.use_llvm = useLlvmWorkaround(target),
});
b.installArtifact(lib);
const lib_step = b.step("lib", "Build the mobile embed static library");
lib_step.dependOn(&b.addInstallArtifact(lib, .{}).step);
if (options.ts_core != null and target.result.abi.isAndroid()) {
// Zig's ELF static-library emission stores the compiled TypeScript
// archives as nested members instead of merging their objects (the
// Mach-O emission merges), and the NDK's -shared host link would
// skip those blobs with only a warning. Flatten to one plain
// object archive so the Android host tier keeps linking exactly
// the archive it already stages.
const merged = mergeMobileArchive(b, dep, lib, options.name);
const lib_name = b.fmt("lib{s}.a", .{options.name});
b.getInstallStep().dependOn(&b.addInstallFileWithDir(merged, .lib, lib_name).step);
lib_step.dependOn(&b.addInstallFileWithDir(merged, .lib, lib_name).step);
} else {
b.installArtifact(lib);
lib_step.dependOn(&b.addInstallArtifact(lib, .{}).step);
}
}
/// Flatten an Android embed library whose members include the compiled
/// TypeScript archives (see the call site above). Runs under node like the
/// rest of the TypeScript lane's drivers.
fn mergeMobileArchive(b: *std.Build, dep: *std.Build.Dependency, lib: *std.Build.Step.Compile, name: []const u8) std.Build.LazyPath {
const node = b.findProgram(&.{"node"}, &.{}) catch
@panic("\nmerging the mobile TypeScript archives needs node on PATH (the TypeScript core lane already requires it).\n");
const merge = b.addSystemCommand(&.{node});
merge.addFileArg(dep.path("packages/core/scripts/merge_static_archives.mjs"));
merge.addArgs(&.{ "--zig", b.graph.zig_exe, "--format", "gnu" });
merge.addArg("--out");
const merged = merge.addOutputFileArg(b.fmt("lib{s}.a", .{name}));
merge.addArg("--in");
merge.addFileArg(lib.getEmittedBin());
return merged;
}
/// The pieces `addApp` wires, for callers that extend the standard app
@@ -1185,14 +1459,6 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
" a Zig core.\nDrop the flag or port the core to TypeScript.\n");
}
}
// Mobile targets are taught BEFORE lane selection: TypeScript cores
// are desktop-only until the external core toolchain grows mobile
// targets; Zig/markup cores stay fully supported on mobile.
if (core_tree == .ts and (target.result.os.tag == .ios or target.result.abi.isAndroid())) {
@panic("\nTypeScript app cores are desktop-only today: the external core compiler does not" ++
" target mobile yet.\nBuild for a desktop target, or port the core to a Zig" ++
" `mobileOptions` app — Zig and markup cores are fully supported on mobile.\n");
}
// The service-carrier selection: `-Dservice-carrier` overrides app.zon's
// `.service_carrier`; both default to auto (the child carrier).
const service_carrier_choice: ServiceCarrierOption = choice: {
@@ -1239,7 +1505,9 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
// artifact the toolkit-owned iOS host (and any hand-written shim)
// links, so `native dev|package --target ios` works against every
// standard app build — generated graph or ejected — with nothing but
// `-Dtarget`. Desktop targets keep the step absent.
// `-Dtarget`. Desktop targets keep the step absent. A TypeScript core
// roots the library's app module at the staged mobile wiring and
// merges the compiled archives into it.
if (target.result.os.tag == .ios or target.result.abi.isAndroid()) {
addMobileLibWithTarget(b, dep, target, optimize, .{
.name = app_options.name,
@@ -1251,6 +1519,13 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A
.credentials_permission = app_config.credentials_permission,
.filesystem_permission = app_config.filesystem_permission,
.credentials_service = app_config.app_id,
.max_image_pixel_bytes = app_config.max_image_pixel_bytes,
.ts_core = if (ts_stage) |stage| .{
.main_root = stage.mobile_root,
.archive = stage.archive,
.service_archive = stage.service_archive,
.manifest_mod = b.createModule(.{ .root_source_file = b.path(appPath(b, app_options.app_root, "app.zon")) }),
} else null,
});
}
const platform_option = b.option(PlatformOption, "platform", "Desktop backend: auto, null, macos, linux, windows") orelse .auto;
@@ -1580,19 +1855,23 @@ fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Resolv
});
app_mod.link_libc = true;
}
addMacosPrivacyInfoPlist(b, app_mod, target, app_config);
addMacosInfoPlist(b, app_mod, target, app_config);
return app_mod;
}
/// Bare Mach-O executables launched by the dev loop do not have an app
/// bundle's external Info.plist. macOS still requires capture usage strings
/// before it will show the consent prompt, so put the permission-derived
/// fragment in the conventional embedded section too. Packaged apps retain
/// their richer external plist generated by package.zig.
fn addMacosPrivacyInfoPlist(b: *std.Build, app_mod: *std.Build.Module, target: std.Build.ResolvedTarget, config: AppManifestBuildConfig) void {
/// bundle's external Info.plist. Embed launch policy plus capture usage
/// strings in the conventional section so LaunchServices starts accessory
/// apps without a transient Dock tile and capture consent can be presented.
/// Packaged apps retain their richer external plist generated by package.zig.
fn addMacosInfoPlist(b: *std.Build, app_mod: *std.Build.Module, target: std.Build.ResolvedTarget, config: AppManifestBuildConfig) void {
if (target.result.os.tag != .macos) return;
if (!config.microphone_permission and !config.system_audio_permission) return;
if (config.dock_visible and !config.microphone_permission and !config.system_audio_permission) return;
const launch_policy = if (!config.dock_visible)
" <key>LSUIElement</key>\\n <true/>\\n"
else
"";
const microphone = if (config.microphone_permission)
" <key>NSMicrophoneUsageDescription</key>\\n <string>This app captures microphone audio when you start recording.</string>\\n"
else
@@ -1603,11 +1882,11 @@ fn addMacosPrivacyInfoPlist(b: *std.Build, app_mod: *std.Build.Module, target: s
else
"";
const source = b.fmt(
\\#define NATIVE_SDK_INFO_PLIST "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n" "<plist version=\"1.0\">\n<dict>\n{s}{s}</dict>\n</plist>\n"
\\#define NATIVE_SDK_INFO_PLIST "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n" "<plist version=\"1.0\">\n<dict>\n{s}{s}{s}</dict>\n</plist>\n"
\\__attribute__((used, section("__TEXT,__info_plist")))
\\static const unsigned char native_sdk_info_plist[sizeof(NATIVE_SDK_INFO_PLIST) - 1] = NATIVE_SDK_INFO_PLIST;
\\
, .{ microphone, system_audio });
, .{ launch_policy, microphone, system_audio });
const generated = b.addWriteFiles().add("native_sdk_macos_info_plist.c", source);
app_mod.addCSourceFile(.{ .file = generated, .flags = &.{} });
}
@@ -1640,8 +1919,10 @@ const sqlite_c_defines = [_][]const u8{
/// Zig deliberately supplies no libc headers for Apple/Android cross targets.
/// Store-capable mobile libraries therefore compile the vendored amalgamation
/// against the same platform SDK the host tier will use to link the archive.
/// Desktop targets keep Zig's ordinary libc discovery.
fn sqliteCFlags(b: *std.Build, target: std.Build.ResolvedTarget) []const []const u8 {
/// Desktop targets keep Zig's ordinary libc discovery. Pub because the SDK's
/// own build graph compiles the same amalgamation into modules that also
/// configure under a mobile -Dtarget (the mobile e2e battery).
pub fn sqliteCFlags(b: *std.Build, target: std.Build.ResolvedTarget) []const []const u8 {
if (target.result.os.tag == .ios) {
const sysroot = b.sysroot orelse iosSdkPath(b, target.result.abi == .simulator) orelse
std.debug.panic("a store-capable iOS library needs the Apple SDK; install Xcode or pass --sysroot <iphone SDK path>", .{});
@@ -1693,6 +1974,35 @@ fn iosSdkPath(b: *std.Build, simulator: bool) ?[]const u8 {
return std.mem.trimEnd(u8, result.stdout, "\r\n");
}
/// Thread the Android NDK location into a ScriptC driver invocation the
/// way `--zig-exe` threads this build's zig: resolved here, at the one
/// boundary that already knows how to discover it, so the compiler's own
/// discovery never depends on the ambient environment. A missing NDK stays
/// quiet — the driver and compiler own the teaching when an Android
/// compile actually needs one.
pub fn addScriptcAndroidNdk(b: *std.Build, run: *std.Build.Step.Run, target: std.Build.ResolvedTarget) void {
if (!target.result.abi.isAndroid()) return;
const ndk_root = androidNdkRootPath(b) orelse return;
run.addArgs(&.{ "--android-ndk", ndk_root });
}
/// The NDK's root directory (the directory holding toolchains/llvm):
/// ANDROID_NDK_ROOT/ANDROID_NDK_HOME wins, else the newest ndk/<version>
/// under the platform SDK location — the same order the compiler's own
/// discovery uses, so threading it changes nothing but the authority.
fn androidNdkRootPath(b: *std.Build) ?[]const u8 {
for ([_][]const u8{ "ANDROID_NDK_ROOT", "ANDROID_NDK_HOME", "ANDROID_NDK_LATEST_HOME" }) |name| {
if (b.graph.environ_map.get(name)) |root| {
if (root.len > 0 and buildDirExists(b, root)) return root;
}
}
const sdk_root = androidSdkRoot(b) orelse return null;
return latestVersionSubdir(b, sdk_root, "ndk") orelse blk: {
const legacy = b.pathJoin(&.{ sdk_root, "ndk-bundle" });
break :blk if (buildDirExists(b, legacy)) legacy else null;
};
}
fn androidNdkSysrootPath(b: *std.Build) ?[]const u8 {
for ([_][]const u8{ "ANDROID_NDK_ROOT", "ANDROID_NDK_HOME", "ANDROID_NDK_LATEST_HOME" }) |name| {
if (b.graph.environ_map.get(name)) |root| {
@@ -2180,6 +2490,7 @@ const AppManifestBuildConfig = struct {
cef_dir: []const u8 = "third_party/cef/macos",
cef_auto_install: bool = false,
webview_layer: WebLayerOption = .auto,
dock_visible: bool = true,
microphone_permission: bool = false,
system_audio_permission: bool = false,
persist_capability: bool = false,
@@ -2192,6 +2503,7 @@ const AppManifestBuildConfig = struct {
credentials_capability: bool = false,
credentials_permission: bool = false,
filesystem_permission: bool = false,
max_image_pixel_bytes: usize = 1024 * 1024,
sqlite_capability: bool = false,
/// The first web declaration found (for teaching messages), or null
/// when app.zon declares no web use. `web_engine = "system"` alone is
@@ -2213,6 +2525,7 @@ const InferenceManifest = struct {
id: []const u8 = "dev.native_sdk.app",
capabilities: []const []const u8 = &.{},
permissions: []const []const u8 = &.{},
dock_visible: bool = true,
web_engine: []const u8 = "system",
webview_layer: []const u8 = "auto",
core_compiler: []const u8 = "external",
@@ -2224,6 +2537,9 @@ const InferenceManifest = struct {
persist: ?struct {
version: u64,
} = null,
images: struct {
max_image_pixel_bytes: usize = 1024 * 1024,
} = .{},
service_packages: []const ServicePackageConfig = &.{},
service_carrier: []const u8 = "auto",
service_pool_size: u8 = 0,
@@ -2274,6 +2590,7 @@ fn appManifestBuildConfig(b: *std.Build, app_root: []const u8) AppManifestBuildC
.cef_dir = raw.cef.dir,
.cef_auto_install = raw.cef.auto_install,
.webview_layer = web_layer_contract.parseWebViewLayer(raw.webview_layer) orelse @panic("app.zon .webview_layer must be \"auto\", \"include\", or \"exclude\""),
.dock_visible = raw.dock_visible,
.microphone_permission = hasManifestPermission(raw.permissions, "microphone"),
.system_audio_permission = hasManifestPermission(raw.permissions, "system_audio"),
.persist_capability = hasManifestCapability(raw.capabilities, "persist"),
@@ -2286,6 +2603,7 @@ fn appManifestBuildConfig(b: *std.Build, app_root: []const u8) AppManifestBuildC
.credentials_capability = hasManifestCapability(raw.capabilities, "credentials"),
.credentials_permission = hasManifestPermission(raw.permissions, "credentials"),
.filesystem_permission = hasManifestPermission(raw.permissions, "filesystem"),
.max_image_pixel_bytes = raw.images.max_image_pixel_bytes,
.sqlite_capability = hasManifestCapability(raw.capabilities, "store") or hasManifestCapability(raw.capabilities, "sqlite"),
.web_declaration = web_layer_contract.manifestDeclaration(raw),
};
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.
+12 -6
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 },
},
@@ -160,10 +158,18 @@ A fuller manifest for an app that also [embeds web content](/docs/frontend) and
<td><code>capabilities</code></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>
<td>Bridge command policies (see <a href="/docs/bridge">Bridge</a>)</td>
@@ -277,7 +283,7 @@ 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`), `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`). `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.
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, `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.
@@ -305,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 = .{
@@ -328,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 = .{
+1 -1
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>
@@ -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 }),
@@ -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."),
@@ -5,6 +5,8 @@ import { AttrTable } from "@/components/attr-table";
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" />
## Markup
+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
+8 -3
View File
@@ -8,16 +8,21 @@ The blob directory is deduplicated but currently has no quota or automatic GC; s
## 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, and `Cmd.statFile` reports `{ exists, size, mtimeMs }` without reading the content.
`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",
})];
```
File outcomes are closed and machine-readable: `ok`, `not_found`, `io_failed`, `truncated`, `rejected`, `cancelled`, `sink_missing`, `out_of_order`, and `disk_full`.
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
@@ -58,7 +63,7 @@ Raw paths inside this app's resolved `data`, `config`, `cache`, `state`, `logs`,
.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, so a symlink inside an app directory that points outside is external and requires the permission. `..` cannot escape an allowed root.
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.
+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{
+9 -7
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 |
@@ -248,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
@@ -320,7 +320,7 @@ export function dropMsg(drop: FileDropEvent): Msg | null {
}
```
The platform event is journaled before either route, so record/replay delivers the identical source, point, and path bytes. A host that cannot resolve the target view leaves `viewLabel` empty and `point` null.
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
@@ -328,7 +328,7 @@ On macOS, every non-virtualized `scroll` region is driven by an invisible `NSScr
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}">
@@ -437,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 without smuggling an `Io` handle from `main` into `update`. Whole-file reads and writes remain bounded at 1 MiB; `appendFile` and `statFile` handle logs and planning, 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).
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(.{
@@ -484,7 +484,9 @@ For timestamps, the facade owns the clocks (Zig 0.16 puts `std.time` behind `std
## Secondary windows
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.
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 {
@@ -573,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
+68 -5
View File
@@ -28,21 +28,84 @@ const MyApp = native_sdk.UiApp(Model, Msg);
.theme = app_runner.manifestThemePack(),
```
In a zero-config TypeScript app, export a single-model `themePack` helper when the pack itself belongs in live app state. The generated launcher recognizes this helper and wires the stock-token path automatically:
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 themePack(model: Model): ThemePack {
return model.theme;
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 };
}
```
Change `model.theme` through ordinary messages (for example, from a pair of model-driven `toggle-button`s). The helper is evaluated on every rebuild. It changes only the built-in pack: system light/dark, high contrast, reduced motion, manifest `theme_accent`, and each surface's scale remain live runtime inputs. Without the helper, `app.zon` remains the static pack choice.
`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:
@@ -113,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
+35 -20
View File
@@ -256,34 +256,49 @@ For multiple Zig-core items, use `UiApp.Options.status_items_fn`, returning up t
## 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()`, and the exported `statusItem(model)` helper above supplies the live title and rows. `examples/menu-bar` is the whole zero-Zig loop.
`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.
@@ -47,7 +47,7 @@ The question behind "can I use npm?" is almost always one of these five:
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. A five-package scriptc 0.0.29 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.
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
+16 -4
View File
@@ -285,8 +285,8 @@ The runtime interprets the command after the model commits and dispatches any re
<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></td>
<td>Append one bounded payload, or inspect <code>&#123; exists, size, mtimeMs &#125;</code> before choosing how to read a file</td>
<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>
@@ -476,6 +476,16 @@ Import the canonical records and unions from `@native-sdk/core/events`. Presenta
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:
@@ -528,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/` 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, `themePack` / `statusItem` / `statusItems`, 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`, the chrome records, `AudioState`/`AudioEvent`, and the status-item state/presentation/row/modifier records and unions) so no core re-types it — 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>
@@ -585,7 +595,9 @@ The core never receives a synchronous handle. Its update returns a command, the
## 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. TypeScript cores remain desktop-only: mobile targets keep Zig and markup cores, and a mobile `-Dtarget` on a TypeScript app teaches the same at configure time.
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
@@ -15,7 +15,7 @@ case "parse":
})];
```
Services run on a supervised carrier — as a separate child process by default, or compiled into the app binary on an explicitly selected worker-thread pool — and are desktop-only today (see [Runtime behavior](#runtime-behavior)).
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
@@ -318,17 +318,17 @@ Two carriers run the same operations behind the same routes, keys, deadlines, ca
<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; available on every supported desktop build</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: native Linux, cross-Linux x86_64/aarch64, native Windows x86_64, cross-Windows x86_64 GNU, or macOS built on macOS</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. `.service_pool_size` (or `-Dservice-pool-size`, 1-16) sets the in-process pool width; the default is min(4, cores).
`.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:
@@ -336,7 +336,7 @@ Shared guarantees:
- **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.** Services are desktop-only today. Child executables 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, native Windows x86_64, cross-Windows x86_64 GNU, or macOS built on macOS. 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.
- **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:
@@ -380,4 +380,4 @@ Three checker rules enforce the boundary. Each teaches the fix and the reason at
## Reference
`examples/service-feed-reader` is the minimal core-plus-service app: a deterministic core, a service using `node:fs`, regex, `Map`, `Date`, and JSON, and a kind-tagged error path. The machine-precise authoring guide ships as `native skills get ts-services`.
[`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`.
+8 -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,
@@ -68,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);
```
@@ -155,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>
@@ -169,11 +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.
`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.
`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.
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.
+6 -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",
+23 -1
View File
@@ -1,5 +1,5 @@
//! Decoder over the app-core Cmd/Sub wire format (rt.zig, cmd_format_version
//! 6), 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
@@ -31,6 +31,7 @@ pub const Op = union(enum) {
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 },
@@ -598,6 +599,10 @@ pub const CmdIter = struct {
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;
@@ -763,6 +768,23 @@ test "record store command records decode and advance exactly" {
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
+1
View File
@@ -17,6 +17,7 @@ TypeScript is the primary app-authoring language. A new `native init my_app` pro
| 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. |
@@ -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 {
@@ -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
+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,
};
}
-1
View File
@@ -18,7 +18,6 @@
.width = 320,
.height = 490,
.resizable = false,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset",
.views = .{
-2
View File
@@ -56,7 +56,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
// the in-canvas drag band carries the window (see view.zig), and
// app.zon's startup window declares the same style.
.resizable = false,
.restore_state = false,
.titlebar = .hidden_inset,
.views = &shell_views,
}};
@@ -152,7 +151,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Calculator",
.bundle_id = "dev.native_sdk.calculator",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.shortcuts = &app_shortcuts,
.security = .{
-1
View File
@@ -12,7 +12,6 @@
.title = "Native SDK Canvas Preview",
.width = 960,
.height = 640,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "preview-canvas", .kind = "gpu_surface", .fill = true, .role = "Canvas chrome", .accessibility_label = "Canvas Preview chrome", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
-2
View File
@@ -49,7 +49,6 @@ pub const shell_windows = [_]native_sdk.ShellWindow{.{
.title = "Native SDK Canvas Preview",
.width = window_width,
.height = window_height,
.restore_state = false,
.views = &shell_views,
}};
pub const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
@@ -196,7 +195,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Native SDK Canvas Preview",
.bundle_id = "dev.native_sdk.canvas_preview",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app", "https://example.com", "https://native-sdk.dev" } },
+3 -1
View File
@@ -8,7 +8,7 @@ This example shows guarded OS capabilities from trusted WebView code:
- Clipboard text read and write.
- Message dialogs.
- Credential set, get, and delete.
- File-drop events delivered to Zig and the WebView event bridge.
- File-drop events delivered to Zig and the WebView event bridge, plus a real canvas `drop_files` target.
- File association and custom URL scheme packaging metadata.
- App activation and deactivation events.
@@ -24,6 +24,8 @@ Run the headless test path:
zig build test -Dplatform=null
```
For the macOS host integration check, run the app with the system backend and drag a Finder file onto the right-hand **Drop files here** canvas. The status bar must report `Widget target 2 fired` and the dropped path. Dropping over the left WebView must still report the ordinary app-level drop without a widget target. The guest-VM harness cannot synthesize an AppKit drag session yet, so this is the documented manual receipt for the real host path.
Run all native-first example tests from the repository root:
```sh
+4 -1
View File
@@ -9,6 +9,7 @@
"webview",
"js_bridge",
"native_views",
"gpu_surfaces",
"open_url",
"reveal_path",
"recent_documents",
@@ -39,11 +40,13 @@
.title = "Native SDK Capabilities",
.width = 900,
.height = 620,
.min_width = 770,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "main", .kind = "webview", .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = "statusbar", .edge = "bottom", .height = 34, .role = "Status" },
.{ .label = "status-label", .kind = "label", .parent = "statusbar", .x = 14, .y = 8, .width = 640, .height = 18, .text = "Ready." },
.{ .label = "drop-canvas", .kind = "gpu_surface", .edge = "right", .width = 250, .min_width = 220, .role = "File drop canvas", .accessibility_label = "File drop target", .gpu_backend = "metal" },
.{ .label = "main", .kind = "webview", .url = "zero://inline", .fill = true, .min_width = 520 },
},
},
},
+125 -7
View File
@@ -10,7 +10,10 @@ const manifest_url_schemes = if (@hasField(@TypeOf(app_manifest), "url_schemes")
const window_width: f32 = 900;
const window_height: f32 = 620;
const window_min_width: f32 = 770;
const statusbar_height: f32 = 34;
const drop_canvas_label = "drop-canvas";
const drop_target_id: native_sdk.canvas.ObjectId = 2;
const html =
\\<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -54,24 +57,30 @@ const builtin_policies = [_]native_sdk.BridgeCommandPolicy{
.{ .name = "native-sdk.credentials.delete", .permissions = &credential_permission, .origins = &bridge_origins },
};
const shell_views = [_]native_sdk.ShellView{
.{ .label = "main", .kind = .webview, .url = "zero://inline", .fill = true },
.{ .label = "statusbar", .kind = .statusbar, .edge = .bottom, .height = statusbar_height, .layer = 20, .role = "Status" },
.{ .label = "status-label", .kind = .label, .parent = "statusbar", .x = 14, .y = 8, .width = 640, .height = 18, .layer = 21, .text = "Ready." },
.{ .label = drop_canvas_label, .kind = .gpu_surface, .edge = .right, .width = 250, .min_width = 220, .role = "File drop canvas", .accessibility_label = "File drop target", .gpu_backend = .metal },
.{ .label = "main", .kind = .webview, .url = "zero://inline", .fill = true, .min_width = 520 },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK Capabilities",
.width = window_width,
.height = window_height,
.min_width = window_min_width,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
const CapabilitiesApp = struct {
drop_count: u32 = 0,
widget_drop_count: u32 = 0,
activation_count: u32 = 0,
deactivation_count: u32 = 0,
last_drop_paths: []const []const u8 = &.{},
last_drop_target_id: native_sdk.canvas.ObjectId = 0,
pending_drop_target_id: ?native_sdk.canvas.ObjectId = null,
drop_target_installed: bool = false,
fn app(self: *@This()) native_sdk.App {
return .{
@@ -96,9 +105,34 @@ const CapabilitiesApp = struct {
self.last_drop_paths = drop.paths;
var status_buffer: [160]u8 = undefined;
const first_path = if (drop.paths.len > 0) drop.paths[0] else "";
const status = try std.fmt.bufPrint(&status_buffer, "Received file drop {d}: {d} file(s): {s}", .{ self.drop_count, drop.paths.len, first_path });
const drop_target_id_value = self.pending_drop_target_id;
self.pending_drop_target_id = null;
const status = if (drop_target_id_value) |target_id|
try std.fmt.bufPrint(&status_buffer, "Widget target {d} fired; app drop {d}: {d} file(s): {s}", .{ target_id, self.drop_count, drop.paths.len, first_path })
else
try std.fmt.bufPrint(&status_buffer, "Received file drop {d}: {d} file(s): {s}", .{ self.drop_count, drop.paths.len, first_path });
_ = try runtime.updateView(drop.window_id, "status-label", .{ .text = status });
},
.canvas_widget_file_drop => |drop| {
self.widget_drop_count += 1;
if (drop.target) |target| {
self.last_drop_target_id = target.id;
self.pending_drop_target_id = target.id;
} else {
self.pending_drop_target_id = null;
}
},
.gpu_surface_frame => |frame| {
if (!self.drop_target_installed and std.mem.eql(u8, frame.label, drop_canvas_label)) {
try installDropTarget(runtime, frame.window_id, frame.label, frame.size);
self.drop_target_installed = true;
}
},
.gpu_surface_resized => |resize| {
if (self.drop_target_installed and std.mem.eql(u8, resize.label, drop_canvas_label)) {
try installDropTarget(runtime, resize.window_id, resize.label, resize.frame.size());
}
},
.lifecycle => |lifecycle| switch (lifecycle) {
.activate => {
self.activation_count += 1;
@@ -110,11 +144,31 @@ const CapabilitiesApp = struct {
},
else => {},
},
.appearance_changed, .command, .shortcut, .timer, .effects_wake, .audio, .video, .gpu_surface_frame, .gpu_surface_resized, .gpu_surface_input, .canvas_widget_pointer, .canvas_widget_keyboard, .canvas_widget_scroll, .canvas_widget_file_drop, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_shown, .canvas_widget_context_menu_dismissed, .canvas_widget_context_menu_request, .canvas_widget_dismiss, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance => {},
.appearance_changed, .command, .shortcut, .timer, .effects_wake, .audio, .video, .gpu_surface_input, .canvas_widget_pointer, .canvas_widget_keyboard, .canvas_widget_scroll, .canvas_widget_drag, .canvas_widget_context_menu, .canvas_widget_context_menu_shown, .canvas_widget_context_menu_dismissed, .canvas_widget_context_menu_request, .canvas_widget_dismiss, .canvas_widget_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance => {},
}
}
};
fn installDropTarget(runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId, label: []const u8, size: native_sdk.geometry.SizeF) !void {
const canvas = native_sdk.canvas;
const margin: f32 = 24;
const target = canvas.Widget{
.id = drop_target_id,
.kind = .button,
.frame = native_sdk.geometry.RectF.init(margin, margin, @max(1, size.width - margin * 2), @max(1, size.height - margin * 2)),
.text = "Drop files here",
.semantics = .{ .label = "Drop files here", .actions = .{ .drop_files = true } },
};
var nodes: [2]canvas.WidgetLayoutNode = undefined;
const layout = try canvas.layoutWidgetTree(
.{ .id = 1, .kind = .panel, .children = &.{target} },
native_sdk.geometry.RectF.init(0, 0, size.width, size.height),
&nodes,
);
_ = try runtime.setCanvasWidgetLayout(window_id, label, layout);
_ = try runtime.emitCanvasWidgetDisplayList(window_id, label, .{});
}
pub fn main(init: std.process.Init) !void {
var app = CapabilitiesApp{};
try runner.runWithOptions(app.app(), .{
@@ -139,6 +193,7 @@ pub fn main(init: std.process.Init) !void {
test "capabilities bridge gates native services and dispatches file drops" {
const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{ .size = native_sdk.geometry.SizeF.init(window_width, window_height) });
defer harness.destroy(std.testing.allocator);
harness.null_platform.gpu_surfaces = true;
harness.runtime.options.builtin_bridge = .{ .enabled = true, .commands = &builtin_policies };
harness.runtime.options.security = .{
.permissions = &app_permissions,
@@ -155,6 +210,30 @@ test "capabilities bridge gates native services and dispatches file drops" {
const app = app_state.app();
try harness.start(app);
var views_buffer: [8]native_sdk.ViewInfo = undefined;
const views = harness.runtime.listViews(1, &views_buffer);
const webview = viewByLabel(views, "main").?;
const drop_canvas = viewByLabel(views, drop_canvas_label).?;
const statusbar = viewByLabel(views, "statusbar").?;
try std.testing.expect(webview.parent == null);
try std.testing.expect(drop_canvas.parent == null);
try std.testing.expectEqual(native_sdk.geometry.RectF.init(0, 0, 650, window_height - statusbar_height), webview.frame);
try std.testing.expectEqual(native_sdk.geometry.RectF.init(650, 0, 250, window_height - statusbar_height), drop_canvas.frame);
try std.testing.expectEqual(native_sdk.geometry.RectF.init(0, window_height - statusbar_height, window_width, statusbar_height), statusbar.frame);
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_frame = .{
.window_id = 1,
.label = drop_canvas_label,
.size = native_sdk.geometry.SizeF.init(250, window_height - statusbar_height),
.frame_index = 1,
.nonblank = true,
} });
try harness.runtime.dispatchPlatformEvent(app, .{ .gpu_surface_resized = .{
.window_id = 1,
.label = drop_canvas_label,
.frame = native_sdk.geometry.RectF.init(0, 0, 350, window_height - statusbar_height),
} });
try dispatchBridge(harness, app, "{\"id\":\"notify\",\"command\":\"native-sdk.os.showNotification\",\"payload\":{\"title\":\"Capabilities\",\"subtitle\":\"native-sdk\",\"body\":\"Done\"}}");
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"ok\":true") != null);
try std.testing.expectEqual(@as(usize, 1), harness.null_platform.notificationCount());
@@ -194,15 +273,37 @@ test "capabilities bridge gates native services and dispatches file drops" {
try std.testing.expect(std.mem.indexOf(u8, harness.null_platform.lastBridgeResponse(), "\"result\":true") != null);
const dropped_paths = [_][]const u8{ "/tmp/one\nname.txt", "/tmp/two.txt" };
try harness.runtime.dispatchPlatformEvent(app, .{ .files_dropped = .{
.window_id = 1,
.paths = &dropped_paths,
} });
try harness.runtime.dispatchPlatformEvent(app, .{
.files_dropped = .{
.window_id = 1,
.view_label = drop_canvas_label,
// x=300 is outside the initial 250-point surface and proves the
// resize event rebuilt the retained widget hit-test geometry.
.point = native_sdk.geometry.PointF.init(300, 40),
.paths = &dropped_paths,
},
});
try std.testing.expectEqual(@as(u32, 1), app_state.widget_drop_count);
try std.testing.expectEqual(drop_target_id, app_state.last_drop_target_id);
try std.testing.expectEqual(@as(u32, 1), app_state.drop_count);
try std.testing.expectEqual(@as(usize, 2), app_state.last_drop_paths.len);
try std.testing.expectEqualStrings("/tmp/one\nname.txt", app_state.last_drop_paths[0]);
try std.testing.expectEqualStrings("/tmp/two.txt", app_state.last_drop_paths[1]);
try std.testing.expectEqualStrings("drop:files", harness.null_platform.lastWindowEventName());
try std.testing.expect(std.mem.startsWith(u8, nullViewText(harness, "status-label"), "Widget target 2 fired"));
try std.testing.expect(app_state.pending_drop_target_id == null);
const webview_paths = [_][]const u8{"/tmp/webview.txt"};
try harness.runtime.dispatchPlatformEvent(app, .{ .files_dropped = .{
.window_id = 1,
.view_label = "main",
.point = native_sdk.geometry.PointF.init(40, 40),
.paths = &webview_paths,
} });
try std.testing.expectEqual(@as(u32, 1), app_state.widget_drop_count);
try std.testing.expect(app_state.pending_drop_target_id == null);
try std.testing.expectEqual(@as(u32, 2), app_state.drop_count);
try std.testing.expect(std.mem.startsWith(u8, nullViewText(harness, "status-label"), "Received file drop 2"));
try harness.runtime.dispatchPlatformEvent(app, .app_activated);
try std.testing.expectEqual(@as(u32, 1), app_state.activation_count);
@@ -213,6 +314,9 @@ test "capabilities bridge gates native services and dispatches file drops" {
}
test "capabilities manifest declares package integration metadata" {
try std.testing.expectEqual(window_min_width, app_manifest.shell.windows[0].min_width);
try std.testing.expectEqual(window_min_width, shell_windows[0].min_width);
try std.testing.expectEqual(@as(usize, 1), manifest_file_associations.len);
try std.testing.expectEqualStrings("Native SDK Capability Document", manifest_file_associations[0].name);
try std.testing.expectEqualStrings("viewer", manifest_file_associations[0].role);
@@ -231,3 +335,17 @@ fn dispatchBridge(harness: *native_sdk.TestHarness(), app: native_sdk.App, bytes
.webview_label = "main",
} });
}
fn nullViewText(harness: *native_sdk.TestHarness(), label: []const u8) []const u8 {
for (harness.null_platform.views[0..harness.null_platform.view_count]) |view| {
if (std.mem.eql(u8, view.label, label)) return view.text;
}
return "";
}
fn viewByLabel(views: []const native_sdk.ViewInfo, label: []const u8) ?native_sdk.ViewInfo {
for (views) |view| {
if (std.mem.eql(u8, view.label, label)) return view;
}
return null;
}
+63 -1
View File
@@ -58,6 +58,11 @@ pub const RunOptions = struct {
if (windows.len > 0) {
info.main_window = windows[0];
info.windows = windows;
} else {
info.main_window.default_frame = manifestShellStartupFrame(info.main_window.default_frame);
info.main_window.restore_state = manifestShellStartupRestoreState(info.main_window.restore_state);
info.main_window.restore_policy = manifestShellStartupRestorePolicy(info.main_window.restore_policy);
info.main_window.initial_placement = manifestShellStartupInitialPlacement(info.main_window.initial_placement);
}
return info;
}
@@ -138,6 +143,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,
};
}
@@ -171,6 +177,57 @@ fn windowRestorePolicy(comptime window: anytype) native_sdk.WindowRestorePolicy
@compileError("unknown app.zon window restore_policy");
}
fn windowHasExplicitOrigin(comptime window: anytype) bool {
return @hasField(@TypeOf(window), "x") or @hasField(@TypeOf(window), "y");
}
fn manifestShellStartupFrame(fallback: native_sdk.geometry.RectF) native_sdk.geometry.RectF {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
const window = shell.windows[0];
return native_sdk.geometry.RectF.init(
windowFloatFallback(window, "x", fallback.x),
windowFloatFallback(window, "y", fallback.y),
windowFloatFallback(window, "width", fallback.width),
windowFloatFallback(window, "height", fallback.height),
);
}
fn windowFloatFallback(comptime window: anytype, comptime field: []const u8, fallback: f32) f32 {
if (comptime @hasField(@TypeOf(window), field)) return @field(window, field);
return fallback;
}
fn manifestShellStartupRestoreState(fallback: bool) bool {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
const window = shell.windows[0];
if (comptime @hasField(@TypeOf(window), "restore_state")) return window.restore_state;
return fallback;
}
fn manifestShellStartupRestorePolicy(fallback: native_sdk.WindowRestorePolicy) native_sdk.WindowRestorePolicy {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
const window = shell.windows[0];
if (comptime !@hasField(@TypeOf(window), "restore_policy")) return fallback;
return windowRestorePolicy(window);
}
fn manifestShellStartupInitialPlacement(fallback: native_sdk.WindowInitialPlacement) native_sdk.WindowInitialPlacement {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
return if (windowHasExplicitOrigin(shell.windows[0])) .explicit else fallback;
}
fn shortcutModifiers(comptime shortcut: anytype) native_sdk.ShortcutModifiers {
const values = if (@hasField(@TypeOf(shortcut), "modifiers")) shortcut.modifiers else .{};
var modifiers: native_sdk.ShortcutModifiers = .{};
@@ -408,12 +465,17 @@ fn prepareStateStore(io: std.Io, env_map: *std.process.Environ.Map, app_info: *n
if (!window.restore_state) continue;
if (store.loadWindow(window.label, &buffers.read) catch null) |saved| {
window.default_frame = saved.frame;
if (index == 0) app_info.main_window.default_frame = saved.frame;
window.initial_placement = .restored;
if (index == 0) {
app_info.main_window.default_frame = saved.frame;
app_info.main_window.initial_placement = .restored;
}
}
}
} else if (app_info.main_window.restore_state) {
if (store.loadWindow(app_info.main_window.label, &buffers.read) catch null) |saved| {
app_info.main_window.default_frame = saved.frame;
app_info.main_window.initial_placement = .restored;
}
}
return store;
-1
View File
@@ -12,7 +12,6 @@
.title = "Native SDK Channel Monitor",
.width = 560,
.height = 420,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "monitor-canvas", .kind = "gpu_surface", .fill = true, .role = "Channel monitor canvas", .accessibility_label = "Channel monitor", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
-2
View File
@@ -38,7 +38,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
.title = "Native SDK Channel Monitor",
.width = window_width,
.height = window_height,
.restore_state = false,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
@@ -306,7 +305,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Native SDK Channel Monitor",
.bundle_id = "dev.native_sdk.channel_monitor",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
-1
View File
@@ -21,7 +21,6 @@
.height = 640,
.min_width = 560,
.min_height = 420,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
-1
View File
@@ -24,7 +24,6 @@
.height = 720,
.min_width = 760,
.min_height = 480,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
-2
View File
@@ -65,7 +65,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
.height = window_height,
.min_width = window_min_width,
.min_height = window_min_height,
.restore_state = false,
.titlebar = .hidden_inset_tall,
.views = &shell_views,
}};
@@ -2038,7 +2037,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Native SDK Code Editor",
.bundle_id = "dev.native_sdk.code_editor",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.shortcuts = &app_shortcuts,
.security = .{
+63 -1
View File
@@ -61,6 +61,11 @@ pub const RunOptions = struct {
if (windows.len > 0) {
info.main_window = windows[0];
info.windows = windows;
} else {
info.main_window.default_frame = manifestShellStartupFrame(info.main_window.default_frame);
info.main_window.restore_state = manifestShellStartupRestoreState(info.main_window.restore_state);
info.main_window.restore_policy = manifestShellStartupRestorePolicy(info.main_window.restore_policy);
info.main_window.initial_placement = manifestShellStartupInitialPlacement(info.main_window.initial_placement);
}
return info;
}
@@ -207,6 +212,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,
};
}
@@ -240,6 +246,57 @@ fn windowRestorePolicy(comptime window: anytype) native_sdk.WindowRestorePolicy
@compileError("unknown app.zon window restore_policy");
}
fn windowHasExplicitOrigin(comptime window: anytype) bool {
return @hasField(@TypeOf(window), "x") or @hasField(@TypeOf(window), "y");
}
fn manifestShellStartupFrame(fallback: native_sdk.geometry.RectF) native_sdk.geometry.RectF {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
const window = shell.windows[0];
return native_sdk.geometry.RectF.init(
windowFloatFallback(window, "x", fallback.x),
windowFloatFallback(window, "y", fallback.y),
windowFloatFallback(window, "width", fallback.width),
windowFloatFallback(window, "height", fallback.height),
);
}
fn windowFloatFallback(comptime window: anytype, comptime field: []const u8, fallback: f32) f32 {
if (comptime @hasField(@TypeOf(window), field)) return @field(window, field);
return fallback;
}
fn manifestShellStartupRestoreState(fallback: bool) bool {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
const window = shell.windows[0];
if (comptime @hasField(@TypeOf(window), "restore_state")) return window.restore_state;
return fallback;
}
fn manifestShellStartupRestorePolicy(fallback: native_sdk.WindowRestorePolicy) native_sdk.WindowRestorePolicy {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
const window = shell.windows[0];
if (comptime !@hasField(@TypeOf(window), "restore_policy")) return fallback;
return windowRestorePolicy(window);
}
fn manifestShellStartupInitialPlacement(fallback: native_sdk.WindowInitialPlacement) native_sdk.WindowInitialPlacement {
if (comptime !@hasField(@TypeOf(app_manifest), "shell")) return fallback;
const shell = app_manifest.shell;
if (comptime !@hasField(@TypeOf(shell), "windows")) return fallback;
if (comptime shell.windows.len == 0) return fallback;
return if (windowHasExplicitOrigin(shell.windows[0])) .explicit else fallback;
}
fn menuItem(comptime item: anytype) native_sdk.MenuItem {
return .{
.label = if (@hasField(@TypeOf(item), "label")) item.label else "",
@@ -509,12 +566,17 @@ fn prepareStateStore(io: std.Io, env_map: *std.process.Environ.Map, app_info: *n
if (!window.restore_state) continue;
if (store.loadWindow(window.label, &buffers.read) catch null) |saved| {
window.default_frame = saved.frame;
if (index == 0) app_info.main_window.default_frame = saved.frame;
window.initial_placement = .restored;
if (index == 0) {
app_info.main_window.default_frame = saved.frame;
app_info.main_window.initial_placement = .restored;
}
}
}
} else if (app_info.main_window.restore_state) {
if (store.loadWindow(app_info.main_window.label, &buffers.read) catch null) |saved| {
app_info.main_window.default_frame = saved.frame;
app_info.main_window.initial_placement = .restored;
}
}
return store;
-1
View File
@@ -23,7 +23,6 @@
.height = 264,
.resizable = false,
.titlebar = "chromeless",
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "deck-canvas", .kind = "gpu_surface", .fill = true, .role = "Deck canvas", .accessibility_label = "Deck music player", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
-2
View File
@@ -66,7 +66,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
// styles instead, which keep the real OS buttons.
.resizable = false,
.titlebar = .chromeless,
.restore_state = false,
.views = &shell_views,
}};
pub const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
@@ -320,7 +319,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Native SDK Deck",
.bundle_id = "dev.native_sdk.deck",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.permissions = &app_permissions,
-1
View File
@@ -12,7 +12,6 @@
.title = "Native SDK Effects Probe",
.width = 560,
.height = 480,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "probe-canvas", .kind = "gpu_surface", .fill = true, .role = "Effects probe canvas", .accessibility_label = "Effects probe", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
-2
View File
@@ -29,7 +29,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
.title = "Native SDK Effects Probe",
.width = window_width,
.height = window_height,
.restore_state = false,
.views = &shell_views,
}};
const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
@@ -206,7 +205,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Native SDK Effects Probe",
.bundle_id = "dev.native_sdk.effects_probe",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
-1
View File
@@ -17,7 +17,6 @@
// (header + fixed-extent timeline rows).
.min_width = 520,
.min_height = 480,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
-2
View File
@@ -56,7 +56,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
.height = window_height,
.min_width = window_min_width,
.min_height = window_min_height,
.restore_state = false,
// Tall hidden-inset titlebar (declared in app.zon too, which threads
// it through the STARTUP window create): the header bar IS the
// titlebar — it pads its leading edge past the traffic lights via
@@ -468,7 +467,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Native SDK Feed",
.bundle_id = "dev.native_sdk.feed",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
+1 -1
View File
@@ -2,7 +2,7 @@
An isolated gallery of the built-in Native UI components, authored entirely in **TypeScript + Native markup**. There is no app-owned Zig: `src/core.ts` owns controlled state, `src/app.native` owns the component tree and specimens, and `app.zon` describes the desktop shell.
The left pane has a live Default/Geist theme-pack selector and a real disclosure `tree` whose rows use the built-in roving keyboard focus and scroll-into-view behavior. The right pane renders only the selected component. The selector changes the pack in the TypeScript model while the runtime keeps following system appearance. Accordion disclosure, dropdown/select/combobox menus, modal surfaces, fields, sliders, tabs, lists, and the focused Tree specimen are all interactive examples of the public markup API.
The left pane has live pack, System/Light/Dark scheme, and accent selectors backed by the TypeScript model and one `themeState(model)` helper. “Default” accent inherits the purple `app.zon` accent; Pink and Teal override it. The pane also contains a real disclosure `tree` whose rows use the built-in roving keyboard focus and scroll-into-view behavior. The right pane renders only the selected component. Accordion disclosure, dropdown/select/combobox menus, modal surfaces, fields, sliders, tabs, lists, and the focused Tree specimen are all interactive examples of the public markup API.
Run the app with the repository CLI:
+1 -1
View File
@@ -4,6 +4,7 @@
.display_name = "GPU Components",
.description = "An isolated gallery of interactive Native UI components authored in TypeScript and Native markup.",
.version = "0.1.0",
.theme_accent = "#7c3aed",
.platforms = .{"macos"},
.permissions = .{ "view", "command" },
.capabilities = .{ "native_views", "gpu_surfaces" },
@@ -16,7 +17,6 @@
.height = 760,
.min_width = 760,
.min_height = 520,
.restore_state = false,
.restore_policy = "center_on_primary",
.views = .{
.{ .label = "components-canvas", .kind = "gpu_surface", .fill = true, .min_width = 640, .role = "Native component gallery", .accessibility_label = "Interactive Native component gallery", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
+1 -1
View File
@@ -3,6 +3,6 @@
"private": true,
"description": "Editor surface for the TypeScript core; the native CLI builds without node_modules.",
"dependencies": {
"@native-sdk/core": "0.9.0"
"@native-sdk/core": "0.9.3"
}
}
+18 -6
View File
@@ -4,10 +4,22 @@
<row background="background">
<column width="248" padding="12" gap="8" background="surface" label="Component navigation">
<column gap="6">
<text foreground="text_muted">Theme</text>
<toggle-group gap="2" label="Theme">
<toggle-button size="sm" selected="{themePack == 'house'}" on-toggle="theme_house">Default</toggle-button>
<toggle-button size="sm" selected="{themePack == 'geist'}" on-toggle="theme_geist">Geist</toggle-button>
<text foreground="text_muted">Pack</text>
<toggle-group gap="2" label="Theme pack">
<toggle-button size="sm" selected="{theme == 'house'}" on-toggle="theme_house">Default</toggle-button>
<toggle-button size="sm" selected="{theme == 'geist'}" on-toggle="theme_geist">Geist</toggle-button>
</toggle-group>
<text foreground="text_muted">Scheme</text>
<toggle-group gap="2" label="Color scheme">
<toggle-button size="sm" selected="{themeColorScheme == 'system'}" on-toggle="theme_system">System</toggle-button>
<toggle-button size="sm" selected="{themeColorScheme == 'light'}" on-toggle="theme_light">Light</toggle-button>
<toggle-button size="sm" selected="{themeColorScheme == 'dark'}" on-toggle="theme_dark">Dark</toggle-button>
</toggle-group>
<text foreground="text_muted">Accent</text>
<toggle-group gap="2" label="Theme accent">
<toggle-button size="sm" selected="{themeAccent == 'manifest'}" on-toggle="accent_manifest">Default</toggle-button>
<toggle-button size="sm" selected="{themeAccent == 'pink'}" on-toggle="accent_pink">Pink</toggle-button>
<toggle-button size="sm" selected="{themeAccent == 'teal'}" on-toggle="accent_teal">Teal</toggle-button>
</toggle-group>
</column>
<separator />
@@ -200,7 +212,7 @@
<column>
<button variant="outline" icon="menu" on-press="open_drawer">Open filters</button>
<if test="{drawerOpen}">
<drawer text="Filters" width="280" padding="24" on-dismiss="close_drawer">
<drawer text="Filters" height="280" padding="24" on-dismiss="close_drawer">
<column gap="12">
<spacer height="34" />
<checkbox checked="{onlyUnreadChecked}" on-toggle="toggle_only_unread" text="Only unread" />
@@ -304,7 +316,7 @@
<column>
<button variant="outline" icon="external-link" on-press="open_sheet">Open share sheet</button>
<if test="{sheetOpen}">
<sheet text="Share" height="210" padding="24" on-dismiss="close_sheet">
<sheet text="Share" width="320" padding="24" on-dismiss="close_sheet">
<column gap="12">
<spacer height="34" />
<text wrap="true" foreground="text_muted">Anyone with the link can view this component gallery.</text>
+36 -6
View File
@@ -3,6 +3,7 @@
// controlled component state and the messages produced by interaction.
import { Cmd, asciiBytes } from "@native-sdk/core";
import { type ThemeState } from "@native-sdk/core/events";
import {
applyTextInputEvent,
clampedInsertEvent,
@@ -109,6 +110,8 @@ function applyDraft(value: Draft, event: TextInputEvent): Draft {
export type Density = "default" | "comfortable";
export type ThemePack = "house" | "geist";
export type ThemeColorScheme = "system" | "light" | "dark";
export type ThemeAccent = "manifest" | "pink" | "teal";
export type DropdownChoice = "none" | "duplicate" | "rename" | "download" | "delete";
export type SelectChoice = "production" | "staging" | "development";
export type Tab = "account" | "password" | "team";
@@ -120,6 +123,8 @@ export interface Model {
readonly components: readonly ComponentItem[];
readonly selectedComponentId: number;
readonly theme: ThemePack;
readonly themeColorScheme: ThemeColorScheme;
readonly themeAccent: ThemeAccent;
readonly catalogExpanded: boolean;
readonly accordionOpen: boolean;
readonly checkboxChecked: boolean;
@@ -157,6 +162,12 @@ export type Msg =
| { readonly kind: "select_component"; readonly componentId: number }
| { readonly kind: "theme_house" }
| { readonly kind: "theme_geist" }
| { readonly kind: "theme_system" }
| { readonly kind: "theme_light" }
| { readonly kind: "theme_dark" }
| { readonly kind: "accent_manifest" }
| { readonly kind: "accent_pink" }
| { readonly kind: "accent_teal" }
| { readonly kind: "toggle_catalog" }
| { readonly kind: "action" }
| { readonly kind: "toggle_accordion" }
@@ -220,7 +231,6 @@ export type Msg =
// These records are intentionally read only through binding helpers.
export const viewUnbound = [
"theme",
"comboboxDraft",
"inputDraft",
"textareaDraft",
@@ -232,6 +242,8 @@ export function initialModel(): Model {
components: COMPONENTS,
selectedComponentId: 1,
theme: "house",
themeColorScheme: "system",
themeAccent: "manifest",
catalogExpanded: true,
accordionOpen: true,
checkboxChecked: true,
@@ -285,11 +297,17 @@ export function selectedLabel(model: Model): Uint8Array {
return asciiBytes("Component");
}
// The default TypeScript launcher recognizes this exported single-model
// helper and selects the built-in pack on every rebuild. System light/dark,
// contrast, reduced-motion, accent, and surface scale remain runtime-owned.
export function themePack(model: Model): ThemePack {
return model.theme;
// One model helper owns the stock theme's author-facing axes. Omitting the
// accent inherits app.zon's theme_accent; `system` follows the OS. High
// contrast/reduced motion and each surface's scale stay runtime-owned.
export function themeState(model: Model): ThemeState {
if (model.themeAccent === "pink") {
return { pack: model.theme, colorScheme: model.themeColorScheme, accent: "#df2670" };
}
if (model.themeAccent === "teal") {
return { pack: model.theme, colorScheme: model.themeColorScheme, accent: "#00786f" };
}
return { pack: model.theme, colorScheme: model.themeColorScheme };
}
export function selectLabel(model: Model): Uint8Array {
@@ -338,6 +356,18 @@ export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
return [{ ...model, theme: "house" }, Cmd.none];
case "theme_geist":
return [{ ...model, theme: "geist" }, Cmd.none];
case "theme_system":
return [{ ...model, themeColorScheme: "system" }, Cmd.none];
case "theme_light":
return [{ ...model, themeColorScheme: "light" }, Cmd.none];
case "theme_dark":
return [{ ...model, themeColorScheme: "dark" }, Cmd.none];
case "accent_manifest":
return [{ ...model, themeAccent: "manifest" }, Cmd.none];
case "accent_pink":
return [{ ...model, themeAccent: "pink" }, Cmd.none];
case "accent_teal":
return [{ ...model, themeAccent: "teal" }, Cmd.none];
case "toggle_catalog":
return [{ ...model, catalogExpanded: !model.catalogExpanded }, Cmd.none];
case "action":
-1
View File
@@ -17,7 +17,6 @@
// (toolbar + hero rail + tiles + forecast form).
.min_width = 1080,
.min_height = 640,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
-2
View File
@@ -122,7 +122,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
.height = window_height,
.min_width = window_min_width,
.min_height = window_min_height,
.restore_state = false,
// Tall hidden-inset titlebar (declared in app.zon too, which threads
// it through the STARTUP window create): the toolbar IS the titlebar
// — its leading reserve shifts right by the live chrome inset so the
@@ -865,7 +864,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Native SDK GPU Dashboard",
.bundle_id = "dev.native_sdk.gpu_dashboard",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.permissions = &app_permissions,
-1
View File
@@ -14,7 +14,6 @@
.title = "Native SDK Habits",
.width = 720,
.height = 520,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
+2 -3
View File
@@ -17,11 +17,10 @@
<column grow="1" gap="12" padding="16">
<!-- Single selection is radio semantics: the group renders selection
from {f == filter} equalities, each radio's on-toggle sets the
model field, and the visible label rides text= (radio is not a
text-bearing element). -->
model field, and the visible label is element content. -->
<radio-group gap="8" label="Filter">
<for each="filters" as="f">
<radio checked="{f == filter}" on-toggle="set_filter:{f}" text="{f}" />
<radio checked="{f == filter}" on-toggle="set_filter:{f}">{f}</radio>
</for>
</radio-group>
<if test="{habit_count}">
-2
View File
@@ -41,7 +41,6 @@ const shell_windows = [_]native_sdk.ShellWindow{.{
.title = "Native SDK Habits",
.width = window_width,
.height = window_height,
.restore_state = false,
// Tall hidden-inset titlebar (declared in app.zon too, which threads
// it through the STARTUP window create): the header row IS the
// titlebar — it pads its leading edge past the traffic lights via
@@ -225,7 +224,6 @@ pub fn main(init: std.process.Init) !void {
.window_title = "Native SDK Habits",
.bundle_id = "dev.native_sdk.habits",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.permissions = &app_permissions,
+7 -1
View File
@@ -129,6 +129,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,
};
}
@@ -391,12 +392,17 @@ fn prepareStateStore(io: std.Io, env_map: *std.process.Environ.Map, app_info: *n
if (!window.restore_state) continue;
if (store.loadWindow(window.label, &buffers.read) catch null) |saved| {
window.default_frame = saved.frame;
if (index == 0) app_info.main_window.default_frame = saved.frame;
window.initial_placement = .restored;
if (index == 0) {
app_info.main_window.default_frame = saved.frame;
app_info.main_window.initial_placement = .restored;
}
}
}
} else if (app_info.main_window.restore_state) {
if (store.loadWindow(app_info.main_window.label, &buffers.read) catch null) |saved| {
app_info.main_window.default_frame = saved.frame;
app_info.main_window.initial_placement = .restored;
}
}
return store;
@@ -359,16 +359,54 @@ final class NativeSdkHostViewController: UIViewController {
private func refreshWidgetAccessibility() {
let semantics = widgetSemanticsSnapshot()
statusLabel.accessibilityValue = "Accessible items: \(semantics.count)"
widgetAccessibilityElements = semantics.map { node in
var elementsById: [UInt64: WidgetAccessibilityElement] = [:]
var nodesById: [UInt64: WidgetSemantics] = [:]
var elements: [WidgetAccessibilityElement] = []
elements.reserveCapacity(semantics.count)
for node in semantics {
let element = WidgetAccessibilityElement(accessibilityContainer: webView, owner: self, node: node)
element.isAccessibilityElement = true
element.accessibilityIdentifier = "native-sdk-widget-\(node.id)"
element.accessibilityLabel = node.label.isEmpty ? node.text : node.label
element.accessibilityValue = widgetAccessibilityValue(node)
element.accessibilityFrameInContainerSpace = node.bounds
element.accessibilityTraits = widgetAccessibilityTraits(node)
return element
elements.append(element)
elementsById[node.id] = element
nodesById[node.id] = node
}
webView.accessibilityElements = widgetAccessibilityElements.isEmpty ? nil : widgetAccessibilityElements as [Any]
var roots: [WidgetAccessibilityElement] = []
var childrenByParentId: [UInt64: [WidgetAccessibilityElement]] = [:]
for (node, element) in zip(semantics, elements) {
guard node.parentId != 0,
let parent = elementsById[node.parentId],
let parentNode = nodesById[node.parentId] else {
roots.append(element)
continue
}
element.accessibilityContainer = parent
element.accessibilityFrameInContainerSpace = CGRect(
x: node.bounds.minX - parentNode.bounds.minX,
y: node.bounds.minY - parentNode.bounds.minY,
width: node.bounds.width,
height: node.bounds.height
)
childrenByParentId[node.parentId, default: []].append(element)
}
for (parentId, children) in childrenByParentId {
guard let parent = elementsById[parentId] else { continue }
parent.accessibilityElements = children as [Any]
parent.accessibilityContainerType = .semanticGroup
// A radiogroup is context for its descendants, not a separate
// stop that hides them. VoiceOver can now announce the group's
// label while navigating its individual radio buttons.
if nodesById[parentId]?.role == Int32(NATIVE_SDK_WIDGET_ROLE_RADIOGROUP) {
parent.isAccessibilityElement = false
}
}
widgetAccessibilityElements = elements.map { $0 as UIAccessibilityElement }
webView.accessibilityElements = roots.isEmpty ? nil : roots as [Any]
}
private func widgetAccessibilityValue(_ node: WidgetSemantics) -> String? {
@@ -439,7 +477,7 @@ final class NativeSdkHostViewController: UIViewController {
switch node.role {
case Int32(NATIVE_SDK_WIDGET_ROLE_BUTTON), Int32(NATIVE_SDK_WIDGET_ROLE_MENUITEM):
traits.insert(.button)
case Int32(NATIVE_SDK_WIDGET_ROLE_CHECKBOX), Int32(NATIVE_SDK_WIDGET_ROLE_SWITCH), Int32(NATIVE_SDK_WIDGET_ROLE_TAB):
case Int32(NATIVE_SDK_WIDGET_ROLE_CHECKBOX), Int32(NATIVE_SDK_WIDGET_ROLE_RADIO), Int32(NATIVE_SDK_WIDGET_ROLE_SWITCH), Int32(NATIVE_SDK_WIDGET_ROLE_TAB):
traits.insert(.button)
case Int32(NATIVE_SDK_WIDGET_ROLE_SLIDER):
traits.insert(.adjustable)
@@ -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 {
+9
View File
@@ -4,6 +4,15 @@ This three-column board is authored entirely in TypeScript + Native markup. `src
Drop one or more files anywhere on the board to add their basenames as Todo cards. The desktop host sends the native file-drop event through the runtime, and the core's `dropMsg` maps the full path list into one deterministic `files_dropped` message before `update` changes the board.
To manually verify the real macOS host path (the guest-VM harness cannot synthesize an AppKit drag session yet):
1. Run `native dev` on macOS.
2. Drag a file from Finder onto a visible card in the canvas, not the titlebar.
3. Verify a new Todo card appears with the dropped file's basename.
4. Repeat over empty board space; the app-level drop still works there.
The first drop traverses the labeled `kanban-canvas` destination with a view-local, top-left-origin point before the app-level `dropMsg` runs. That is the same host data widget `drop_files` hit-testing consumes; the second verifies the ordinary canvas-level fallback remains intact.
Drag any card within a column or across Todo, Doing, and Done. The card itself lifts under the pointer at full opacity, leaving one blank, card-sized slot behind. As the pointer reaches another candidate position, that same reserved slot moves from the source to the candidate and neighboring cards glide around it—there are never two spaces for one card. On release, the same floating card eases from the pointer into the slot. Press Escape during a drag to cancel it and carry the card back to its source slot. Cards can move forwards, backwards, or directly across the board.
Each card represents an agent-owned ticket: the title sits above a compact metadata row with a Jira/Linear-style issue key and the assigned OpenAI or Claude avatar. The avatar artwork is rasterized from SVGL's [OpenAI](https://svgl.app/library/openai.svg) and [Claude AI](https://svgl.app/library/claude-ai-icon.svg) SVGs so it can travel through the app manifest's static image channel.
-1
View File
@@ -27,7 +27,6 @@
// (proven by the layout audit sweep).
.min_width = 840,
.min_height = 560,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
+1 -1
View File
@@ -3,6 +3,6 @@
"private": true,
"description": "Editor surface for the TypeScript core; the native CLI builds without node_modules.",
"dependencies": {
"@native-sdk/core": "0.9.0"
"@native-sdk/core": "0.9.3"
}
}
-1
View File
@@ -19,7 +19,6 @@
// resizing here instead of clipping the panes below it.
.min_width = 960,
.min_height = 560,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{

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