Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d465af4926 | |||
| f228e861d1 | |||
| 6ec836c491 | |||
| 9ce0370181 | |||
| a7665807f3 | |||
| fe92cff10a | |||
| 7d67158444 | |||
| c71a7b4638 | |||
| d7aeea1ea9 | |||
| 8b2a97ffe7 | |||
| 473cad71ef | |||
| 06b6ccd53c | |||
| ed84e35975 | |||
| 5fa8074f7c | |||
| 8d7946edbd | |||
| 011caa9183 | |||
| b7c493bab0 | |||
| aa7ed9aa52 | |||
| 833e79e44a | |||
| cafbf206e8 | |||
| 0126d20f30 | |||
| 2fd7c4c3dc | |||
| 4c95b04539 | |||
| c7e64b647c | |||
| a404ca166d | |||
| 7f6830a15b | |||
| 7a29661384 | |||
| a727b1db68 | |||
| 7e3a3157d0 | |||
| 283ab804c0 | |||
| 83a7aee721 | |||
| 30c1410c10 | |||
| bfcc5ff8df | |||
| 3a580c9a8d | |||
| 4269233703 | |||
| b230b140b8 | |||
| 04125b7d62 | |||
| 21f6057041 | |||
| 4f0b57f2c2 | |||
| b21849c1bf | |||
| ff6a1c2c32 | |||
| b01851d03c | |||
| 0f990c2007 | |||
| 4ceffdbbd5 | |||
| 030bf8df74 | |||
| d976ab6351 | |||
| d26428e11b | |||
| 31c140e26f | |||
| e84ee28cbe | |||
| 1789c68049 | |||
| a14d225f0f | |||
| 8fc933b9db | |||
| 8600d7e5d5 | |||
| bdfce1ea7d |
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Windows canvas smoke under Wine.
|
||||
#
|
||||
# Exercises the Windows gpu_surface software path (src/platform/windows/
|
||||
# webview2_host.cpp: child HWND, WM_TIMER frame events, SetDIBitsToDevice
|
||||
# blits) without Windows hardware: cross-compiles examples/ui-inbox for
|
||||
# Exercises the Windows gpu_surface Direct2D path (src/platform/windows/
|
||||
# webview2_host.cpp: child HWND, WM_TIMER frame events, retained binary
|
||||
# packets) under Wine: cross-compiles examples/ui-inbox for
|
||||
# x86_64-windows-gnu, runs the .exe under Xvfb + Wine, and asserts against
|
||||
# the automation snapshot:
|
||||
#
|
||||
# 1. snapshot ready=true (app booted, automation server live)
|
||||
# 2. gpu_backend=software (the SetDIBitsToDevice path is active)
|
||||
# 2. gpu_backend=direct2d (the retained packet path is active)
|
||||
# 3. gpu_nonblank=true (real pixels were presented)
|
||||
# 4. widget-click "Add task" -> '4 open' (automation input mutates state)
|
||||
# 5. real X11 click + typing lands in the draft textbox (XTEST -> Wine ->
|
||||
@@ -116,9 +116,9 @@ app_pid=$!
|
||||
poll 180 'ready=true' || fail "snapshot never became ready"
|
||||
echo "== ready: $(head -1 "$snap" | cut -d'|' -f1)"
|
||||
|
||||
# ---- 2 + 3: software backend presented non-blank pixels --------------------
|
||||
# ---- 2 + 3: Direct2D backend presented non-blank pixels --------------------
|
||||
poll 60 'gpu_nonblank=true' || fail "gpu_nonblank never became true"
|
||||
poll 10 'gpu_backend=software' || fail "gpu_backend is not software"
|
||||
poll 10 'gpu_backend=direct2d' || fail "gpu_backend is not direct2d"
|
||||
echo "== canvas: $(grep -o 'gpu_backend=[a-z]*' "$snap" | head -1)" \
|
||||
"$(grep -o 'gpu_nonblank=[a-z]*' "$snap" | head -1)" \
|
||||
"$(grep -o 'gpu_sample=0x[0-9a-f]*' "$snap" | head -1)" \
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
# the app's trace log:
|
||||
#
|
||||
# 1. snapshot ready=true (app booted, automation server live)
|
||||
# 2. gpu_backend=software + nonblank (the canvas presented real pixels)
|
||||
# 2. gpu_backend=direct2d + nonblank (the canvas presented real pixels)
|
||||
# 3. widget-click "Start stream" (fx.spawn launches cmd.exe under
|
||||
# Wine; streamed lines land in the
|
||||
# model and grow the snapshot)
|
||||
@@ -118,9 +118,9 @@ app_pid=$!
|
||||
poll 180 'ready=true' || fail "snapshot never became ready"
|
||||
echo "== ready: $(head -1 "$snap" | cut -d'|' -f1)"
|
||||
|
||||
# ---- 2: software backend presented non-blank pixels ------------------------
|
||||
# ---- 2: Direct2D backend presented non-blank pixels ------------------------
|
||||
poll 60 'gpu_nonblank=true' || fail "gpu_nonblank never became true"
|
||||
grep -q 'gpu_backend=software' "$snap" || fail "gpu_backend is not software"
|
||||
grep -q 'gpu_backend=direct2d' "$snap" || fail "gpu_backend is not direct2d"
|
||||
echo "== canvas: $(grep -o 'gpu_backend=[a-z]*' "$snap" | head -1)" \
|
||||
"$(grep -o 'gpu_nonblank=[a-z]*' "$snap" | head -1)"
|
||||
grep -q 'idle' "$snap" || fail "probe did not start idle"
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
|
||||
|
||||
+63
-64
@@ -15,74 +15,63 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# The TypeScript core suites transpile at build/test time; without
|
||||
# node they skip silently, so CI must provide it.
|
||||
# TypeScript cores compile through the external core compiler at
|
||||
# build/test time; the compiler and the frontend's toolchain both
|
||||
# arrive with this one install (without it the ts-core suites skip
|
||||
# silently, so CI must provide it). No SCRIPTC_NO_CACHE and no
|
||||
# cache action on purpose: hosted runners are ephemeral, so any
|
||||
# per-run compiler cache dies with the VM and runs stay hermetic
|
||||
# across commits by machine lifecycle.
|
||||
- run: npm ci --prefix packages/core
|
||||
- name: Service surface tooling and claims
|
||||
run: node --test packages/core/test/surface_tools.test.ts
|
||||
- run: zig build test
|
||||
- run: zig build validate
|
||||
|
||||
compiled-core-parity:
|
||||
name: Compiled-Core Parity
|
||||
core-compiler-fences:
|
||||
name: Core Compiler Fences
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# The transpiled lane of every pairing runs the repo's own transpiler under node at build time; it needs its installed dependency.
|
||||
# The exact-pinned external core compiler and the frontend's
|
||||
# toolchain, one install (packages/core/package.json is the one
|
||||
# place the pin lives).
|
||||
- run: npm ci --prefix packages/core
|
||||
# The external core compiler, at the release the profiles' determinism fence tables are pinned to. tests/compiled-core/core_compiler_pin is the ONE place the pin lives — build_core.sh refuses any other release, so a bump is a one-line change there and this step follows.
|
||||
- name: Install the external core compiler
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pin="$(cat tests/compiled-core/core_compiler_pin)"
|
||||
npm install --prefix .zig-cache/core-compiler "scriptc@${pin}"
|
||||
compiler="$PWD/.zig-cache/core-compiler/node_modules/.bin/scriptc"
|
||||
test "$("$compiler" -v)" = "$pin"
|
||||
echo "NATIVE_SDK_CORE_COMPILER=$compiler" >> "$GITHUB_ENV"
|
||||
# Per-fixture contract artifacts the external compile consumes: the effective sidecar plus its generated entry module and compiler profile, under zig-out/core-contracts.
|
||||
# Per-fixture contract artifacts the fixture driver consumes: the effective sidecar plus its generated entry module and compiler profile, under zig-out/core-contracts.
|
||||
- run: zig build stage-core-contracts
|
||||
# Determinism-fence negative control: the pristine markup fixture compiles and its co-emitted sidecar attests deterministic: true, then one injected ambient read (Date.now() in update) must be refused by the profile's fences — proving the fences fire, not merely that clean cores pass under them.
|
||||
# Determinism-fence negative control: the pristine markup fixture compiles and its co-emitted sidecar attests deterministic: true, then one injected ambient read (Date.now() in update) must be refused by the profile's fences — proving the fences fire, not merely that clean cores pass under them. The positive batteries (every fixture's e2e suite over its real archive) ride `zig build test` in the Zig Core job; this job holds the refusal half.
|
||||
- name: Determinism fences fire (negative control)
|
||||
run: tests/compiled-core/fence_check.sh .zig-cache/fence-check
|
||||
- name: Build the five fixture cores
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for fixture in host-fixture soundboard system-monitor ai-chat markup; do
|
||||
tests/compiled-core/build_core.sh "$fixture" ".zig-cache/compiled-cores/$fixture"
|
||||
done
|
||||
# Each fixture app's OWN e2e battery over a paired core — the transpiled lane vs the compiled archive, byte-compared at every seam. Locally this step is env-gated (no external compiler on a stock checkout); this job is where it always runs. Serial (-j1): the soundboard battery measures wall-clock dispatch budgets, and five test binaries racing on a two-core runner turn scheduler contention into failures the budgets were never meant to catch.
|
||||
- name: Run the compiled-core parity battery
|
||||
run: zig build test-compiled-core-parity -j1
|
||||
env:
|
||||
NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_HOST: ${{ github.workspace }}/.zig-cache/compiled-cores/host-fixture/libhost_fixture_core.a
|
||||
NATIVE_SDK_EXTERNAL_CORE_SIDECAR_HOST: ${{ github.workspace }}/.zig-cache/compiled-cores/host-fixture/core.contract.json
|
||||
NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_SOUNDBOARD: ${{ github.workspace }}/.zig-cache/compiled-cores/soundboard/libsoundboard_core.a
|
||||
NATIVE_SDK_EXTERNAL_CORE_SIDECAR_SOUNDBOARD: ${{ github.workspace }}/.zig-cache/compiled-cores/soundboard/core.contract.json
|
||||
NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_SYSTEM_MONITOR: ${{ github.workspace }}/.zig-cache/compiled-cores/system-monitor/libsystem_monitor_core.a
|
||||
NATIVE_SDK_EXTERNAL_CORE_SIDECAR_SYSTEM_MONITOR: ${{ github.workspace }}/.zig-cache/compiled-cores/system-monitor/core.contract.json
|
||||
NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_AI_CHAT: ${{ github.workspace }}/.zig-cache/compiled-cores/ai-chat/libai_chat_core.a
|
||||
NATIVE_SDK_EXTERNAL_CORE_SIDECAR_AI_CHAT: ${{ github.workspace }}/.zig-cache/compiled-cores/ai-chat/core.contract.json
|
||||
NATIVE_SDK_EXTERNAL_CORE_ARCHIVE_MARKUP: ${{ github.workspace }}/.zig-cache/compiled-cores/markup/libmarkup_core.a
|
||||
NATIVE_SDK_EXTERNAL_CORE_SIDECAR_MARKUP: ${{ github.workspace }}/.zig-cache/compiled-cores/markup/core.contract.json
|
||||
|
||||
macos-webview:
|
||||
name: macOS WebView
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# gpu-components is a TypeScript-core app, so its smoke build needs
|
||||
# the frontend compiler and exact-pinned TypeScript toolchain.
|
||||
- run: npm ci --prefix packages/core
|
||||
# The mobile aggregate runs on Linux for Android. Exercise the other
|
||||
# store-capable cross-target here against the real iPhone simulator SDK.
|
||||
- run: zig build test-example-mobile-canvas-lib-ios-store
|
||||
- run: zig build test-webview-system-link
|
||||
- run: zig build test-webview-smoke
|
||||
# Signed-package seal pin: an ad-hoc signed package must pass
|
||||
@@ -107,12 +96,12 @@ jobs:
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
# Percentile perf check: 5 cold launches asserting p90
|
||||
# first-frame latency under NATIVE_SDK_PERF_BUDGET_MS, then 5 steady-state
|
||||
# widget clicks asserting p90 input latency under NATIVE_SDK_PERF_INPUT_BUDGET_MS.
|
||||
# first-frame latency, 5 steady-state widget clicks asserting p90 input
|
||||
# latency, then a reset-scoped retained-animation cadence sample.
|
||||
# Its own job so a shared-runner slowdown is visible in isolation and
|
||||
# never blocks the correctness smokes.
|
||||
# Shared macos-14 runners are far noisier than a dev box (first CI run
|
||||
@@ -123,13 +112,20 @@ jobs:
|
||||
env:
|
||||
NATIVE_SDK_PERF_BUDGET_MS: "1500"
|
||||
NATIVE_SDK_PERF_INPUT_BUDGET_MS: "500"
|
||||
# The virtual display is commonly 25-35 Hz even though AppKit
|
||||
# reports the window visible. Keep physical/dev defaults at the
|
||||
# strict 60 Hz-class 45/20/34; this hosted job remains a
|
||||
# step-function regression sentinel rather than a display gate.
|
||||
NATIVE_SDK_PERF_ANIMATION_MIN_FRAMES: "30"
|
||||
NATIVE_SDK_PERF_ANIMATION_P90_MS: "50"
|
||||
NATIVE_SDK_PERF_ANIMATION_MAX_MS: "100"
|
||||
|
||||
linux-webkitgtk:
|
||||
name: Linux WebKitGTK
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- name: Install WebKitGTK dependencies
|
||||
@@ -150,7 +146,7 @@ jobs:
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
# Builds the WebView example with the system engine, compiling the
|
||||
@@ -180,14 +176,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# The TypeScript core suites transpile at build/test time; without
|
||||
# node they skip silently, so CI must provide it.
|
||||
# The TypeScript core suites compile through the external core
|
||||
# compiler at build/test time; without the install they skip
|
||||
# silently, so CI must provide it.
|
||||
- run: npm ci --prefix packages/core
|
||||
- run: zig build test-tooling
|
||||
|
||||
@@ -219,14 +216,15 @@ jobs:
|
||||
step: test-examples-native-shard-4
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# The TypeScript examples transpile at build time; the transpiler
|
||||
# needs its installed dependency.
|
||||
# The TypeScript examples compile through the external core
|
||||
# compiler at build time; the compiler and the frontend toolchain
|
||||
# arrive with this install.
|
||||
- run: npm ci --prefix packages/core
|
||||
# Every example test uses the null backend, so this lane needs no
|
||||
# GTK/WebKitGTK packages. The root build owns the round-robin shard
|
||||
@@ -238,7 +236,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
# Declare-to-use, proven on real Windows executables: the
|
||||
@@ -270,7 +268,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
# Deliberately NO libwebkitgtk-6.0-dev: ui-inbox declares no web
|
||||
@@ -296,7 +294,7 @@ jobs:
|
||||
# C diagnostics on failure, which is exactly the escalation this
|
||||
# step pins against. The throwaway cache dir keeps the compile
|
||||
# cold: on a cache hit zig replays nothing, stderr included, so a
|
||||
# warm cache (setup-zig restores one) would hide the diagnostics
|
||||
# warm compiler cache would hide the diagnostics
|
||||
# this step exists to catch.
|
||||
- name: WebKitGTK stub compile is diagnostic-free
|
||||
run: |
|
||||
@@ -352,14 +350,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# The scaffold default is the TypeScript core; its transpiler runs
|
||||
# under node at build time from this checkout's packages/core.
|
||||
# The scaffold default is the TypeScript core; its frontend and
|
||||
# compiler run at build time from this checkout's packages/core.
|
||||
- run: npm ci --prefix packages/core
|
||||
# No WebKitGTK dev package, same as linux-canvas-smoke: the scaffold
|
||||
# declares no web use, so its host compiles with the stub seam.
|
||||
@@ -382,7 +380,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- name: Install Wine, Xvfb, and xdotool
|
||||
@@ -404,8 +402,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
# Cross-compiles ui-inbox for x86_64-windows-gnu and drives the
|
||||
# gpu_surface software path (child HWND + WM_TIMER + SetDIBitsToDevice)
|
||||
# under Wine: snapshot ready, gpu_backend=software, gpu_nonblank=true,
|
||||
# retained Direct2D packet path (child HWND + WM_TIMER) under Wine:
|
||||
# snapshot ready, gpu_backend=direct2d, gpu_nonblank=true,
|
||||
# automation widget-click, and real XTEST pointer/keyboard input.
|
||||
# Wineprefix init happens inline in the script (measured 21s from
|
||||
# scratch in an ubuntu-24.04 container, so no cache step).
|
||||
@@ -418,7 +416,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- name: Install Wine and Xvfb
|
||||
@@ -437,7 +435,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- run: zig build test-examples-frontends
|
||||
@@ -447,7 +445,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- run: zig build test-examples-mobile
|
||||
@@ -457,14 +455,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# The default scaffold is a TypeScript core: its build runs the
|
||||
# @native-sdk/core transpiler from this checkout's own install.
|
||||
# @native-sdk/core frontend and the external core compiler from
|
||||
# this checkout's own install.
|
||||
- run: npm ci --prefix packages/core
|
||||
- run: zig build
|
||||
- name: Scaffold and test the zero-config native app
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ packages/native-sdk/build.zig.zon
|
||||
packages/native-sdk/app.zon
|
||||
packages/native-sdk/third_party/
|
||||
packages/native-sdk/packages/
|
||||
packages/native-sdk/tools/
|
||||
packages/native-sdk/LICENSE
|
||||
# npm pack output
|
||||
packages/native-sdk/*.tgz
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
Guidance for agents (and humans) working on this repository.
|
||||
|
||||
## App authoring default
|
||||
|
||||
Native SDK itself is implemented in Zig, but Native SDK **apps are authored in TypeScript + Native markup by default**. Do not infer the app-authoring language from this repository's implementation language or from older Zig-core examples.
|
||||
|
||||
- For a new app, use `native init <path>` and expect `src/core.ts`, `src/app.native`, and `app.zon`. Ordinary compiled TypeScript work that needs filesystem, process, JSON, regex, classes, or other static-tier APIs belongs under optional `src/services/`, reached from the core with `Cmd.request`; do not import a service from the core. Do not add Zig app code unless the user explicitly chooses `--template zig-core` or the feature requires a toolkit extension.
|
||||
- Before changing an existing app, inspect its tree. A `src/core.ts` app stays TypeScript; a `src/main.zig` app stays Zig unless the task is specifically a migration.
|
||||
- For default app work, read `skill-data/native-ui/SKILL.md` and `skill-data/ts-core/SKILL.md`; also read `skill-data/ts-services/SKILL.md` when the tree has `src/services/` or the task needs ordinary TypeScript beyond the core subset. `skill-data/core/SKILL.md` covers shared/runtime concerns; `skill-data/zig/SKILL.md` is for Zig-core apps and SDK implementation work.
|
||||
- The `-ts` suffix on a few examples only distinguishes ports from older Zig originals. New TypeScript apps need no suffix because TypeScript is the default.
|
||||
|
||||
## Build, test, and gate
|
||||
|
||||
```bash
|
||||
@@ -20,7 +29,7 @@ Pinned goldens (pixel signatures, schema fingerprints, command counts) are updat
|
||||
|
||||
## Changelog
|
||||
|
||||
Do not edit `CHANGELOG.md` directly. Each user-visible change ships a fragment in `changelog.d/` — see `changelog.d/README.md` for the format and voice. Internal-only polish needs no fragment.
|
||||
Do not edit `CHANGELOG.md` as part of regular feature or fix work. The release agent reviews the git history since the previous release and writes the complete changelog entry during release preparation; see [RELEASING.md](./RELEASING.md).
|
||||
|
||||
## Where things live
|
||||
|
||||
|
||||
+161
-3
@@ -2,12 +2,172 @@
|
||||
|
||||
All notable changes to the Native SDK (formerly zero-native) will be documented in this file.
|
||||
|
||||
## 0.7.1
|
||||
## 0.9.0
|
||||
|
||||
<!-- release:start -->
|
||||
|
||||
### New Features
|
||||
|
||||
- **Ordinary TypeScript services behind a typed boundary**: Apps can place filesystem, process, JSON, regex, class, and exact-vendored npm work under `src/services/`; Native SDK generates the checked client and codecs, compiles a pinned static service executable, and carries keyed requests, typed streaming, cooperative cancellation, deadlines, supervision, and deterministic replay across the isolated boundary (#317, #321).
|
||||
- **Optional in-process TypeScript services**: Services can use the same boundary through a linked, runtime-localized worker pool with per-key FIFO ordering, parallel independent keys, streaming, timeout and trap isolation, and replay that never starts the carrier; the explicit opt-in now follows the compiler's Windows, Linux, macOS, and cross-target matrix while the isolated child remains the automatic default (#334, #337).
|
||||
- **Engine-owned model persistence**: TypeScript cores can persist committed models through capability-gated, atomically replaced snapshots with generated codecs, debounced writes, backup recovery, explicit restore and migration routes, rollback safety, and journal/replay support (#316).
|
||||
- **SQLite record storage**: TypeScript and Zig apps can use a capability-gated record store for deterministic atomic CRUD effects backed by bundled SQLite across desktop and mobile hosts, with devhost parity and a complete Record Store example (#320).
|
||||
- **Checked relational SQLite**: Append-only migrations, build-validated named SQL, transactions, generated typed commands and live-query subscriptions, replay, and the Relational Notes example make relational SQLite a first-class offline data layer across desktop and mobile (#326).
|
||||
- **Model-driven menu-bar apps**: TypeScript apps can derive status-item labels, icons, tooltips, and rich menus from committed model state, while new macOS effects control hidden startup, fullscreen, Dock visibility, and launch-at-login behavior across both native hosts (#311, #314).
|
||||
- **Platform services for TypeScript cores**: Typed effects now open external URLs, reveal filesystem paths, and format local time through validated macOS, Linux, and Windows backends (#315).
|
||||
- **App-scoped credentials**: TypeScript and Zig cores can store, load, and delete credentials through capability- and permission-gated native providers, with redacted journals, deterministic replay placeholders, and hermetic devhost stores across desktop and mobile (#335).
|
||||
- **Cross-compiled TypeScript cores**: The external core compiler now builds Linux and Windows GNU targets from macOS, Linux, or Windows and macOS targets from macOS, with target-independent contracts and cross-platform end-to-end batteries for Windows and Linux musl (#340).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Working documentation root**: `/docs`, `/docs/`, and the matching Markdown route now resolve to the Introduction instead of ending at a 404 (#338).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Faster retained desktop frames**: Animation pumping and Windows wake scheduling now avoid stalled or redundant work, profiling uses monotonic frame-correlated telemetry, and physical macOS and Windows performance gates protect input latency and frame budgets (#313).
|
||||
- **Measured, compiler-truth service tooling**: A dedicated TypeScript Services reference documents the two-tier model and failure semantics; production-carrier benchmarks measure cold start, latency, and throughput; generated compiler-surface references and manifest diffs keep capability claims mechanically honest; and scriptc advances through 0.0.28 with refreshed contracts and calibration (#325, #327, #328, #329, #333, #336).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @carvalab
|
||||
- @Railly
|
||||
- @camilocbarrera
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.8.4
|
||||
|
||||
### New Features
|
||||
|
||||
- **Streaming fetch responses for TypeScript cores**: `Cmd.fetch` can now deliver line-framed HTTP responses through typed message arms with deterministic terminal errors, loud cancellation, duplicate-key rejection, and bounded line sizes; the rebuilt Chatbot example streams Vercel AI Gateway replies with live model selection and a Stop action (#300).
|
||||
- **Desktop audio capture**: TypeScript cores can start bounded, timestamped microphone or system-output PCM streams on macOS and Windows with explicit lifecycle, permission, drop-count, and replay handling; the new Voice Memo example records, saves, and plays WAV files (#303).
|
||||
- **Customizable macOS DMG packaging**: `native package` now creates polished drag-to-Applications disk images with generated or custom Retina backgrounds, configurable Finder geometry, positioned app and Applications entries, and staged files, directories, or links (#304).
|
||||
- **Live TypeScript theme packs**: zero-config TypeScript apps can export `themePack(model)` to switch the built-in theme pack from app state without losing live system scheme, accessibility, accent, or scale inputs (#308).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Smooth macOS dialog blur**: Host backdrop blur now uses an optimized three-pass Gaussian approximation and correct dirty-region invalidation, eliminating flat or stale dialog backgrounds while preserving the established scrim treatment (#299).
|
||||
- **Byte-accurate PTY event keys**: TypeScript PTY event routes now expose echoed session keys as `Uint8Array`, matching the byte-text host, generated facade, and external-core contract (#307).
|
||||
- **Reliable keyboard widget navigation**: Interactive canvas lists, trees, menus, and anchored controls now retain logical focus across clipped rows, scroll keyboard targets into view, and paint active and focus-visible states consistently (#308).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **TypeScript component gallery**: The GPU component showcase is now a TypeScript core and Native markup app with isolated interactive specimens, model-driven Default and Geist switching, clearer navigation, and dedicated smoke coverage (#308).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @marcusschiesser
|
||||
- @NyxTools-M
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Packaged TypeScript app assets**: Runtime asset lookup now finds bundled macOS resources before the process working directory, so Native markup boot images such as the Kanban agent avatars render after launch (#297).
|
||||
- **Unclipped drag landing motion**: Dropped cards now stay in the lifted drag layer through their landing animation while neighboring reflow remains clipped within its swimlane (#297).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Denser Kanban showcase**: The seeded board now includes twice as many Jira-style tickets and removes redundant issue glyphs from card metadata (#297).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### New Features
|
||||
|
||||
- **Native drag and drop for TypeScript apps**: Native markup's new `on-drag` channel delivers live, release, and cancellation geometry to compiled cores while the renderer lifts the source under the pointer, preserves one globally keyed insertion slot, animates neighboring items, and supports Escape cancellation; TypeScript cores can also map native multi-file drops into ordinary deterministic messages through `dropMsg` (#285).
|
||||
- **Desktop notifications from model cores**: TypeScript apps can return fire-and-forget `Cmd.showNotification` effects and Zig apps can call `fx.showNotification`, with bounded validation and suppression during fake execution and session replay (#283).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Explicit zero canvas padding**: Programmatic and compiled or interpreted Native markup views now preserve `padding="0"` instead of replacing it with the widget kind's default padding (#288).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **TypeScript-first app authoring guidance**: Repository instructions, bundled skills, examples, package documentation, and the docs site now consistently lead with TypeScript cores and Native markup for new apps while keeping Zig as the explicit alternative and toolkit-extension tier (#284).
|
||||
- **Agent ticket Kanban showcase**: The TypeScript Kanban example now presents numbered OpenAI- and Claude-assigned tickets, uses an icon-only add action, keeps columns scrollable, and extends its end-to-end coverage for the updated drag geometry (#295).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @johnlindquist
|
||||
- @Railly
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### New Features
|
||||
|
||||
- **Safe presentational HTML in Markdown**: Markdown now lowers common GitHub-style inline and block HTML into native widgets, including links, details, aligned containers, and caller-resolved images, while scripts, styles, forms, embeds, event attributes, and unsupported or malformed markup remain inert literal text (#280).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Reliable resolved Markdown images**: image discovery now follows renderable block starts, canonicalizes entity-encoded URLs consistently between loading and lookup, preserves aspect ratios within declared bounds, honors centered and end alignment, and ignores images inside comments, unsupported markup, code, and preformatted blocks (#281).
|
||||
- **Payload-free HTTP write requests**: `Effects.fetch` now sends an explicit zero-length body for POST, PUT, and PATCH requests without a payload, preventing debug-build crashes and emitting the required `Content-Length: 0` header (#277).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **History-driven release notes**: release preparation now builds the complete changelog entry and contributor list from the commits since the previous release, replacing the per-change fragment workflow (#278).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @Railly
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### New Features
|
||||
|
||||
- **Compiler-truth checks for TypeScript cores**: `native check` now ends with the pinned external core compiler's analyzer over the entry with the shipped SDK declarations mapped, so check and build share one compiler verdict. Type errors the frontend's own line would miss fail with the compiler's diagnostics verbatim; an analyzer that cannot reach a verdict defers to the build instead of wedging check.
|
||||
- **TypeScript cores compile through the external core compiler**: the frontend checks `src/core.ts` and emits its contract sidecar, the exact-pinned compiler builds a native archive, and the app links a generated mirror over it — no JS runtime in the binary, nothing to configure.
|
||||
- **The TS-to-Zig transpiled lane is removed** (a deliberate pre-1.0 break): `core_compiler = "transpiler"` in app.zon (and `-Dcore-compiler=transpiler`) is refused with a teaching, and `native check` runs the checker and contract only — no emitted Zig lands under `.native/check/`.
|
||||
- **The compiler is a package dependency**: it ships exact-pinned with the SDK's `packages/core` (repo checkouts install it with `npm ci` there; an npm-installed CLI carries it automatically).
|
||||
- **The core dev loop is restart-shaped**: markup hot reload and the instant `native dev --core` node loop are unchanged, and a core edit now pays a native compile measured in seconds on rebuild.
|
||||
- **TypeScript cores are desktop-only for now**: a mobile target with `src/core.ts` is taught before lane selection (the external toolchain does not target mobile yet); Zig and markup cores stay fully supported on mobile.
|
||||
- **Shipped type declarations**: `@native-sdk/core` now ships generated `sdk/*.d.ts` declaration files beside its TypeScript sources, so external tooling can resolve the SDK's types without compiling them.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Leaner TypeScript toolchain installs**: the unused `@typescript/typescript6` compatibility wrapper is no longer a dependency of `@native-sdk/cli` or `@native-sdk/core`. The frontend already imports its compiler directly through the exactly pinned `@typescript/old` alias, while consumer trees carrying their own wrapper remain unaffected.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
|
||||
## 0.7.2
|
||||
|
||||
### New Features
|
||||
|
||||
- **Geist-style code diffs**: `ui.code` and `<code>` can mark added and removed logical lines with theme-aware full-row washes, renderer-owned `+`/`-` markers, optional line numbers, and unchanged syntax-highlighted clipboard source.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Verified Zig setup**: repository and generated CI workflows now install Zig with `vercel-labs/setup-zig`, including signed archive and checksum verification.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Theme-accurate disabled buttons**: disabled buttons now keep shadcn's knockout label treatment in the default theme and use Geist's gray-100/gray-700 swap, gray-400 edge, and distinct half-opacity tertiary register in the Geist theme.
|
||||
- **Canonical documentation routes**: documentation now lives under `/docs/`, with permanent redirects from every previous URL, explicit canonical metadata, `.md` siblings, and a generated `llms.txt` that stays aligned with each page's canonical MDX source.
|
||||
- **Geist primary tabs match the design system**: tab strips now use the reference 50px row, full-width bottom rail, content-hugging 14px triggers, 24px spacing, and 16px icon treatment without changing default-theme pill tabs; the GPU component gallery now pairs a compact theme picker with a scrollable component tree and focused specimen views.
|
||||
- **Quiet Windows subprocesses**: `Effects.spawn` no longer opens or flashes a console window when a GUI or tray app launches a console-subsystem helper such as `node.exe`; interactive terminal children remain on the separate PTY API.
|
||||
- **Responsive Windows GPU surfaces**: Windows now renders retained binary canvas packets with Direct2D and DirectWrite, applies dirty-region patches (including edge-safe GPU-resident backdrop blur), and limits RGBA-to-BGRA conversion and invalidation to dirty pixels when software fallback is required.
|
||||
- **Exact Windows packet text and chrome**: Packet rendering now refuses when the bundled/custom font path cannot preserve engine-planned metrics, preserves explicitly positioned glyph runs, prevents system glyph substitution, and samples a covered, changed hidden-titlebar pixel for native caption contrast.
|
||||
- **Truthful GPU backend types**: TypeScript creation options now expose only portable backend requests while view and frame state can report the concrete Direct2D renderer; explicit software requests bypass packet encoding and image uploads and stay on the reference renderer and pixel presenter.
|
||||
- **Reliable registered-image replacement**: Unregistering and then re-registering identical pixels now recreates the removed GPU resource instead of retaining a stale cache key and silently omitting the image.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @oshtz
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### New Features
|
||||
|
||||
- **Declarative folder-to-code editor example**: `examples/code-editor` authors its complete view in hot-reloadable `.native` markup, unifies its titlebar, file pane, tab-strip canvas, and editor background, centers the opened folder name beside a trailing ghost Save icon in a custom titlebar, opens or replaces the focused window's folder with Cmd+O, creates independent editor windows with Cmd+N, builds a clean bounded folders-first disclosure tree with outline-free selection-only Up/Down navigation, leaf-to-parent Left movement, Left/Right expansion, in-place disk-backed Enter rename, Cmd+Enter permanent tabs, and folder focus independent from the active editor, and presents editable syntax-highlighted files (including `.mjs` and large practical sources) in a resizable second pane with flat VS Code-style tabs whose active tab has no top accent and breaks the baseline to meet the editor, replaceable italic previews that pin when double-clicked, dirty dots, active/hover close buttons, native Close/Close Others tab menus, wrapping Cmd+Shift+[/] tab cycling, Cmd+W tab-or-empty-window closing, and serialized disk-backed Save/Cmd+S.
|
||||
- **Generated compiled-core facade**: `corewire --facade` now emits the complete compiler entry and matching profile from the contract sidecar, including explicit `--f64-slot` demotions, authored type provenance, and signed or unsigned integer proofs at every host ingress.
|
||||
- **Facade contract hardening**: generated entries preserve subdirectory module paths, reconstruct private reachable types without invalid imports, preserve Model-first resolution for homonymous unbound bindings, decode optional and composite record fields with a running cursor, prove nullable integer helpers, handle signed and unsigned text-selection sentinels consistently, and refuse legacy sidecars that lack the authored facts a facade requires.
|
||||
@@ -47,8 +207,6 @@ All notable changes to the Native SDK (formerly zero-native) will be documented
|
||||
|
||||
- @ctate
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### New Features
|
||||
|
||||
+2
-2
@@ -123,6 +123,6 @@ Branch from `main` (fork first if you don't have push access), keep the change f
|
||||
scripts/gate.sh fast # root suites + the example suites your diff touches
|
||||
```
|
||||
|
||||
If the change is user-visible, add a changelog fragment in `changelog.d/` (see [changelog.d/README.md](./changelog.d/README.md)) instead of editing `CHANGELOG.md`. Open the PR against `main` describing what changed and why; for larger changes, open an issue first so the design can be discussed.
|
||||
Do not edit `CHANGELOG.md` as part of a feature or fix PR; the release agent writes the complete entry from the release-range history. Open the PR against `main` describing what changed and why; for larger changes, open an issue first so the design can be discussed.
|
||||
|
||||
Commits must be cryptographically signed (`git commit -S`, or set `commit.gpgsign = true`) so they show as **Verified** — the `Signed-off-by` trailer from `git commit -s` is a DCO attestation, not a signature.
|
||||
Commits must be cryptographically signed (`git commit -S`, or set `commit.gpgsign = true`) so they show as **Verified** — the `Signed-off-by` trailer from `git commit -s` is a DCO attestation, not a signature.
|
||||
|
||||
@@ -93,17 +93,20 @@ Read the full guide at [native-sdk.dev/quick-start](https://native-sdk.dev/quick
|
||||
|
||||
## Examples
|
||||
|
||||
The apps pictured above live in [examples/](./examples), most as zero-config projects — `app.zon` plus `src/`, no build files — run straight from their directory with `native dev`.
|
||||
The apps pictured above live in [examples/](./examples), most as zero-config projects — `app.zon` plus `src/`, no build files — run straight from their directory with `native dev`. Start with the TypeScript examples when learning the primary authoring path. The `-ts` suffix on `soundboard-ts` and `system-monitor-ts` is historical because those apps are ports kept beside older Zig originals. Chatbot is TypeScript-only and follows the unsuffixed naming used by new apps created with `native init`.
|
||||
|
||||
| Example | What it shows |
|
||||
| --- | --- |
|
||||
| [`chatbot`](./examples/chatbot) | TypeScript + Native markup end to end: modules, a text editor, streaming fetch effects, and replay-safe configuration. |
|
||||
| [`soundboard-ts`](./examples/soundboard-ts) | The full music-player showcase in TypeScript + Native markup: audio, search, assets, timers, and context menus. |
|
||||
| [`system-monitor-ts`](./examples/system-monitor-ts) | A live process monitor in TypeScript + Native markup: subprocess effects, tables, charts, and timers. |
|
||||
| [`calculator`](./examples/calculator) | A complete small app: markup keypad, keyboard input, chrome shortcuts, theming. |
|
||||
| [`notes`](./examples/notes) | Persistence through the effects channel: debounced writes, restore on boot, dialogs, search. |
|
||||
| [`soundboard`](./examples/soundboard) | Album grid with decoded cover art, context menus, timers, and a custom theme. |
|
||||
| [`deck`](./examples/deck) | The soundboard player rebuilt as a dense hardware chassis: two windows, same widgets, different tokens. |
|
||||
| [`feed`](./examples/feed) | A 100,000-row list, virtualized with runtime-owned scrolling. |
|
||||
|
||||
The full catalog in [examples/README.md](./examples/README.md) also covers guarded OS capabilities, GPU surfaces, WebView composition, web-frontend shells, and the iOS/Android embed hosts.
|
||||
The unsuffixed showcase apps above predate the TypeScript default and retain their Zig cores as first-class alternative implementations. The full catalog in [examples/README.md](./examples/README.md) also covers guarded OS capabilities, GPU surfaces, WebView composition, web-frontend shells, and the iOS/Android embed hosts.
|
||||
|
||||
## Platforms
|
||||
|
||||
|
||||
+10
-5
@@ -7,11 +7,16 @@ To prepare a release:
|
||||
1. Create a branch (e.g. `prepare-v1.2.0`)
|
||||
2. Bump the version in `packages/native-sdk/package.json`
|
||||
3. Run `npm --prefix packages/native-sdk run version:sync` to update all version references
|
||||
4. Run `scripts/changelog-merge.sh` to fold any pending `changelog.d/` fragments into the `## Unreleased` section
|
||||
5. Write the changelog entry in `CHANGELOG.md`, wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers
|
||||
6. Populate the entry's `### Contributors` from commit authors and `Co-authored-by` trailers in the release range, using GitHub handles when available; this marked block is also the GitHub release body
|
||||
7. Remove the `<!-- release:start -->` and `<!-- release:end -->` markers from the previous release entry; only the latest release should have markers
|
||||
8. Open a PR and merge to `main`
|
||||
4. Review the git history since the previous release and write the complete changelog entry at the top of `CHANGELOG.md`, under a new `## <version>` heading wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers
|
||||
5. Populate the entry's `### Contributors` from commit authors and `Co-authored-by` trailers in the release range, using GitHub handles when available; this marked block is also the GitHub release body
|
||||
6. Remove the `<!-- release:start -->` and `<!-- release:end -->` markers from the previous release entry; only the latest release should have markers
|
||||
7. Open a PR and merge to `main`
|
||||
|
||||
## Writing the changelog
|
||||
|
||||
Follow the existing format and voice. Group changes under descriptive headings such as `### New Features`, `### Bug Fixes`, and `### Improvements`. Give each bullet a bold lead-in followed by a concise description, and include PR numbers when available. Do not prefix entries with commit hashes.
|
||||
|
||||
The release entry should cover the complete git range since the previous release, including changes whose individual PRs did not touch `CHANGELOG.md`.
|
||||
|
||||
CI compares the version in `packages/native-sdk/package.json` to what's on npm. If it differs, it cross-builds the CLI for every platform, creates the GitHub release with the binaries, publishes the per-platform binary packages (`packages/native-sdk/npm/*`), and publishes `@native-sdk/cli` last — so the main package only lands once every binary package it pins is live. If npm already has the version but the GitHub release is missing assets, CI recreates the GitHub release from the marked changelog entry.
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"src",
|
||||
"templates",
|
||||
"tests",
|
||||
"third_party/sqlite",
|
||||
"tools",
|
||||
},
|
||||
}
|
||||
|
||||
+1147
-95
File diff suppressed because it is too large
Load Diff
+6
-8
@@ -61,15 +61,13 @@ if (typeof module.registerHooks !== 'function') {
|
||||
load(url, context, nextLoad) {
|
||||
if (url.startsWith('file:') && url.endsWith('.ts')) {
|
||||
const filePath = fileURLToPath(url);
|
||||
// The transpiler's own pinned compiler, resolved from the target
|
||||
// The frontend's own pinned compiler, resolved from the target
|
||||
// module's location (packages/core/node_modules after the taught
|
||||
// `npm ci`, or the dependency npm installed beside the CLI). The
|
||||
// ALIAS is required directly — not the @typescript/typescript6
|
||||
// wrapper — because the wrapper's re-export resolves
|
||||
// "@typescript/old" from the WRAPPER's own location, where a
|
||||
// consumer tree's conflicting hoisted copy would win nearest-wins
|
||||
// over our exact pin; resolving from the target finds our own
|
||||
// nested/hoisted pin first (same reasoning as typed_ast.ts).
|
||||
// `npm ci`, or the dependency npm installed beside the CLI):
|
||||
// resolving from the target finds our own nested/hoisted exact
|
||||
// pin first, so a consumer tree's conflicting hoisted typescript
|
||||
// never wins nearest-wins over it (same reasoning as
|
||||
// typed_ast.ts).
|
||||
if (ts === null) {
|
||||
try {
|
||||
ts = createRequire(targetPath)('@typescript/old');
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Changelog fragments
|
||||
|
||||
Agents and feature branches do not edit `CHANGELOG.md` directly — concurrent work would conflict on every merge. Instead, each change lands with a small fragment in this directory, and `scripts/changelog-merge.sh` folds all fragments into the `## Unreleased` section of `CHANGELOG.md` (typically during release prep, see RELEASING.md).
|
||||
|
||||
## Writing a fragment
|
||||
|
||||
Add `changelog.d/<slug>.md`, where `<slug>` names your change (e.g. `gpu-dashboard-smoke-budget.md`). The file holds a bullet or two for one changelog section:
|
||||
|
||||
- The first line starts with a section tag: `feature:`, `improvement:`, or `fix:`, followed by the first bullet's text.
|
||||
- Any further lines are additional bullets (start them with `- `; bare lines get `- ` prefixed for you).
|
||||
- One tag per fragment. A change that touches multiple sections ships multiple fragments.
|
||||
- Match the CHANGELOG voice: bold lead-in, then the story. One line per bullet — never hard-wrap.
|
||||
|
||||
Example (`changelog.d/faster-frobnication.md`):
|
||||
|
||||
```
|
||||
improvement: **Faster frobnication**: the frobnicator now memoizes per-frame, cutting rebuild time ~40% on the kanban example.
|
||||
- **Frobnication telemetry**: automation snapshots report `frob_cache_hits=`.
|
||||
```
|
||||
|
||||
Tags map to sections: `feature:` → `### New Features`, `improvement:` → `### Improvements`, `fix:` → `### Bug Fixes`.
|
||||
|
||||
## Merging
|
||||
|
||||
```sh
|
||||
scripts/changelog-merge.sh
|
||||
```
|
||||
|
||||
appends every fragment's bullets to the end of its section under `## Unreleased` (creating the section — or the whole `## Unreleased` block — when missing), then deletes the merged fragments. This `README.md` is never merged or deleted. The script refuses unknown tags loudly instead of guessing.
|
||||
@@ -1 +0,0 @@
|
||||
fix: **Canonical documentation routes**: documentation now lives under `/docs/`, with permanent redirects from every previous URL, explicit canonical metadata, `.md` siblings, and a generated `llms.txt` that stays aligned with each page's canonical MDX source.
|
||||
@@ -1 +0,0 @@
|
||||
fix: **Quiet Windows subprocesses**: `Effects.spawn` no longer opens or flashes a console window when a GUI or tray app launches a console-subsystem helper such as `node.exe`; interactive terminal children remain on the separate PTY API.
|
||||
@@ -58,6 +58,13 @@ const nextConfig = {
|
||||
return [
|
||||
// The Philosophy page became the Introduction, the opening page of the docs.
|
||||
{ source: "/philosophy", destination: "/docs/introduction", permanent: true },
|
||||
// docsSlugs() only yields nested slugs, so the /docs segment itself has
|
||||
// neither a route nor a generated redirect and 404s. It is the parent of
|
||||
// every documentation link on the site and the likeliest hand-typed entry
|
||||
// point, so open it on the Introduction instead. The .md sibling keeps the
|
||||
// Markdown surface whole for agents that reach for it.
|
||||
{ source: "/docs", destination: "/docs/introduction", permanent: true },
|
||||
{ source: "/docs.md", destination: "/docs/introduction.md", permanent: true },
|
||||
...legacyDocsRedirects,
|
||||
];
|
||||
},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
@@ -1,11 +1,11 @@
|
||||
// Regression pin for the Code component docs preview. ComponentPreview
|
||||
// Regression pin for the Code component docs previews. ComponentPreview
|
||||
// deliberately keeps a webp fallback under its live canvas, so a stale
|
||||
// or incompatible wasm scene can otherwise fail silently and leave the
|
||||
// page looking correct while it is only showing the screenshot.
|
||||
//
|
||||
// Require the checked-in module to instantiate the exact `code` scene
|
||||
// used by /components/code. Runs after `next build` as part of
|
||||
// `pnpm check`.
|
||||
// Require the checked-in module to instantiate the exact `code` and
|
||||
// `code-diff` scenes used by /components/code. Runs after `next build`
|
||||
// as part of `pnpm check`.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -18,33 +18,46 @@ const bytes = readFileSync(wasmPath);
|
||||
const vocab = JSON.parse(readFileSync(vocabPath, "utf8"));
|
||||
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||
const exports = instance.exports;
|
||||
const scene = new TextEncoder().encode("code");
|
||||
const scenePtr = exports.preview_alloc(scene.length);
|
||||
const sceneNames = ["code", "code-diff"];
|
||||
|
||||
if (!scenePtr) {
|
||||
throw new Error("code WASM preview check could not allocate its scene name");
|
||||
}
|
||||
|
||||
new Uint8Array(exports.memory.buffer).set(scene, scenePtr);
|
||||
const handle = exports.preview_create(scenePtr, scene.length, 0);
|
||||
exports.preview_free(scenePtr, scene.length);
|
||||
|
||||
if (!handle) {
|
||||
if (
|
||||
typeof exports.preview_code_diff_metadata_round_trip !== "function" ||
|
||||
exports.preview_code_diff_metadata_round_trip() !== 1
|
||||
) {
|
||||
throw new Error(
|
||||
"the checked-in component-preview.wasm cannot create the `code` scene; rebuild it with `zig build docs-wasm-preview`",
|
||||
"the checked-in component-preview.wasm truncates code-diff metadata above line 96 on wasm32",
|
||||
);
|
||||
}
|
||||
|
||||
const width = exports.preview_logical_width(handle);
|
||||
const height = exports.preview_logical_height(handle);
|
||||
exports.preview_destroy(handle);
|
||||
const expectedWidth = vocab.previews.code.width / 2;
|
||||
const expectedHeight = vocab.previews.code.height / 2;
|
||||
for (const sceneName of sceneNames) {
|
||||
const scene = new TextEncoder().encode(sceneName);
|
||||
const scenePtr = exports.preview_alloc(scene.length);
|
||||
|
||||
if (width !== expectedWidth || height !== expectedHeight) {
|
||||
throw new Error(
|
||||
`the code WASM preview is ${width}x${height}; expected the catalog's ${expectedWidth}x${expectedHeight} scene`,
|
||||
);
|
||||
if (!scenePtr) {
|
||||
throw new Error(`${sceneName} WASM preview check could not allocate its scene name`);
|
||||
}
|
||||
|
||||
new Uint8Array(exports.memory.buffer).set(scene, scenePtr);
|
||||
const handle = exports.preview_create(scenePtr, scene.length, 0);
|
||||
exports.preview_free(scenePtr, scene.length);
|
||||
|
||||
if (!handle) {
|
||||
throw new Error(
|
||||
`the checked-in component-preview.wasm cannot create the \`${sceneName}\` scene; rebuild it with \`zig build docs-wasm-preview\``,
|
||||
);
|
||||
}
|
||||
|
||||
const width = exports.preview_logical_width(handle);
|
||||
const height = exports.preview_logical_height(handle);
|
||||
exports.preview_destroy(handle);
|
||||
const expectedWidth = vocab.previews[sceneName].width / 2;
|
||||
const expectedHeight = vocab.previews[sceneName].height / 2;
|
||||
|
||||
if (width !== expectedWidth || height !== expectedHeight) {
|
||||
throw new Error(
|
||||
`the ${sceneName} WASM preview is ${width}x${height}; expected the catalog's ${expectedWidth}x${expectedHeight} scene`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`${sceneName} WASM preview check passed: live scene instantiated at ${width}x${height}`);
|
||||
}
|
||||
|
||||
console.log(`code WASM preview check passed: live scene instantiated at ${width}x${height}`);
|
||||
|
||||
@@ -11,7 +11,7 @@ A Native SDK app is one loop with four parts:
|
||||
|
||||
The runtime owns everything else: window creation, GPU presentation, resize, pointer and keyboard dispatch, timers, accessibility, and hot reload. Your code never handles a raw event — input lands on a widget, the widget's bound message dispatches into `update`, the view rebuilds from the new model, and the engine repaints what changed.
|
||||
|
||||
The loop is the same in both authoring languages. By default the core is TypeScript (`src/core.ts`, compiled to native code at build time — [TypeScript Cores](/docs/typescript) covers that tier in depth); a Zig core (`src/main.zig`, from `native init --template zig-core`) is first-class by choice, and the rest of this page — wiring, identity, hot reload — applies to both. The Zig-specific wiring sections below are exactly what the build generates for a TypeScript app, so they double as its eject story.
|
||||
The loop is the same in both authoring languages. By default the core is TypeScript (`src/core.ts`, compiled to native code at build time — [TypeScript Cores](/docs/typescript) covers that tier in depth); a Zig core (`src/main.zig`, from `native init --template zig-core`) is first-class by choice, and the rest of this page — wiring, identity, hot reload — applies to both. The Zig-specific wiring sections below are exactly what the build generates for a TypeScript app, so they double as the blueprint for porting a core to Zig by hand.
|
||||
|
||||
## The loop in full
|
||||
|
||||
@@ -81,7 +81,7 @@ Markup can never mutate state. `{count}` is a read; `on-press="increment"` names
|
||||
|
||||
## Wiring
|
||||
|
||||
`native_sdk.UiApp(Model, Msg)` ties the loop to the runtime. A zero-config app never writes this — the build graph generates it (for a TypeScript core, over the transpiled model) — but it is ordinary code you can own any time. From the Zig template's `main`:
|
||||
`native_sdk.UiApp(Model, Msg)` ties the loop to the runtime. A zero-config app never writes this — the build graph generates it (for a TypeScript core, over the compiled core's model) — but it is ordinary code you can own any time. From the Zig template's `main`:
|
||||
|
||||
```zig
|
||||
const CounterApp = native_sdk.UiApp(Model, Msg);
|
||||
@@ -156,7 +156,7 @@ A Zig-root app keeps dev-time hot reload for its embedded fragments too: build w
|
||||
|
||||
## Side effects
|
||||
|
||||
`update` stays pure by routing anything asynchronous — subprocesses, HTTP, file persistence, timers, clipboard — through the effects channel, and results come back as ordinary messages. In a TypeScript core, effects are `Cmd` data returned from `update` and recurring timers are declared `Sub` data — see [TypeScript Cores: Effects](/docs/typescript#effects-are-cmd-data). In a Zig core, declare `.update_fx` instead of `.update` and spawn from message arms; boot-time work goes in `.init_fx`, which runs exactly once before the first paint. See [Native UI: Effects](/docs/native-ui#effects).
|
||||
`update` stays pure by routing anything that leaves the model — subprocesses, HTTP, file persistence, timers, clipboard, desktop notifications — through the effects channel, and routed results come back as ordinary messages. In a TypeScript core, effects are `Cmd` data returned from `update` and recurring timers are declared `Sub` data — see [TypeScript Cores: Effects](/docs/typescript#effects-are-cmd-data). In a Zig core, declare `.update_fx` instead of `.update` and spawn from message arms; boot-time work goes in `.init_fx`, which runs exactly once before the first paint. See [Native UI: Effects](/docs/native-ui#effects).
|
||||
|
||||
## Dropping down
|
||||
|
||||
|
||||
@@ -148,13 +148,21 @@ A fuller manifest for an app that also [embeds web content](/docs/frontend) and
|
||||
<td><code>platforms</code></td>
|
||||
<td>Target platforms: <code>macos</code>, <code>linux</code>, <code>windows</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>dmg</code></td>
|
||||
<td>Optional macOS DMG presentation: volume name, PNG/JPEG/TIFF background (with automatic adjacent <code>@2x</code> discovery), usable Finder canvas and icon sizes, simple app/Applications positions, or an explicit positioned <code>items</code> list of the app, Applications alias, project files/directories, and absolute links. The zero-config defaults produce a complete drag-to-Applications layout.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>permissions</code></td>
|
||||
<td>Runtime permissions (see <a href="/docs/security">Security</a>)</td>
|
||||
<td>Runtime permissions (see <a href="/docs/security">Security</a>). Audio capture uses <code>microphone</code> and <code>system_audio</code>; macOS packaging emits the matching microphone, audio-capture, and screen-capture usage descriptions only when declared</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>capabilities</code></td>
|
||||
<td>Feature declarations (see <a href="/docs/security">Security</a>)</td>
|
||||
<td>Feature declarations (see <a href="/docs/security">Security</a>). <code>"store"</code> links the engine-owned record store; <code>"sqlite"</code> links relational SQL effects. They share one capability-shed SQLite object but use separate databases; see <a href="/docs/record-store">Record Store</a> and <a href="/docs/sqlite">Relational SQLite</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>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>bridge</code></td>
|
||||
@@ -269,15 +277,15 @@ For a scene-first app — a `UiApp` passing its Zig scene (`shell_scene`) to the
|
||||
},
|
||||
```
|
||||
|
||||
Each window takes a `label` plus optional `title`, `width`, `height`, `x`, `y`, `resizable`, `restore_state`, `restore_policy` (`clamp_to_visible_screen` or `center_on_primary`), `min_width`/`min_height` (a content min-size floor the window itself enforces — macOS `contentMinSize`; the first shell window's declaration threads through the startup create like `titlebar`, negative values are a manifest error, 0 means no floor), and `titlebar` (`standard`, `hidden_inset`, `hidden_inset_tall` — the tall variant centers macOS's traffic lights in the 52pt unified band for toolbar-height headers — or `chromeless`, the fully-skinned opt-in that removes all OS chrome including the system buttons; only for apps that draw their own working window controls, see `examples/deck`). `titlebar = "hidden_inset"` hides the titlebar and extends content under it (macOS keeps the traffic lights) — the first shell window's declaration threads through the STARTUP window create, so the main window's chrome is right from the first frame; the app's own header then takes over dragging and inset padding through the `window-drag` attribute and the `on_chrome` hook (see <a href="/docs/native-ui">Native UI</a>). Platforms without the concept keep standard chrome. The same `titlebar` field is accepted on top-level `windows` entries.
|
||||
Each window takes a `label` plus optional `title`, `width`, `height`, `x`, `y`, `resizable`, `restore_state`, `restore_policy` (`clamp_to_visible_screen` or `center_on_primary`), `initially_hidden` (default false; create the native window ordered out until an explicit show/focus), `allows_fullscreen` (default true; false disables native fullscreen on macOS without disabling ordinary resizing), `min_width`/`min_height` (a content min-size floor the window itself enforces — macOS `contentMinSize`; the first shell window's declaration threads through the startup create like `titlebar`, negative values are a manifest error, 0 means no floor), and `titlebar` (`standard`, `hidden_inset`, `hidden_inset_tall` — the tall variant centers macOS's traffic lights in the 52pt unified band for toolbar-height headers — or `chromeless`, the fully-skinned opt-in that removes all OS chrome including the system buttons; only for apps that draw their own working window controls, see `examples/deck`). `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.
|
||||
|
||||
Overlay presentation is also fixed at create time: `transparent` makes the top-level window alpha-capable, `always_on_top` selects its floating/topmost level, `click_through` passes pointer input to windows underneath, and `activate_on_show = false` reveals it without activating the app or taking focus. Canvas windows already use present-before-show, so these flags are applied while the window is hidden and its first alpha-correct frame becomes the first visible frame. Pair `transparent = true` with a non-opaque `gpu_alpha_mode`; `UiApp.WindowDescriptor` makes that canvas-alpha choice and uses an alpha-zero clear automatically. See the <a href="/docs/windows#overlay-windows">overlay window recipe</a>. These fields are accepted on top-level `windows`, `shell.windows`, runtime `WindowCreateOptions`, and `UiApp.WindowDescriptor`.
|
||||
Overlay presentation is also fixed at create time: `transparent` makes the top-level window alpha-capable, `always_on_top` selects its floating/topmost level, `click_through` passes pointer input to windows underneath, `activate_on_show = false` reveals it without activating the app or taking focus, and `allows_fullscreen = false` removes macOS fullscreen participation while keeping the window resizable. Canvas windows already use present-before-show, so these flags are applied while the window is hidden and its first alpha-correct frame becomes the first visible frame. `initially_hidden = true` is stronger: it suppresses that reveal until `Cmd.showWindow` or explicit focus. Pair `transparent = true` with a non-opaque `gpu_alpha_mode`; `UiApp.WindowDescriptor` makes that canvas-alpha choice and uses an alpha-zero clear automatically. See the <a href="/docs/windows#overlay-windows">overlay window recipe</a>. These fields are accepted on top-level `windows` and `shell.windows`. Runtime `WindowCreateOptions` exposes the same controls but spells the hidden mode `.show = .hidden`; `UiApp.WindowDescriptor` exposes the overlay controls but not the initially-hidden mode.
|
||||
|
||||
Windows also take `close_policy` (`quit`, the default — the close affordance really closes, behavior unchanged for every existing app — or `hide`, the menu-bar-app shape: close hides the window and the app keeps running behind its status item). Like `titlebar`, close handling is host window state fixed at create, and the first shell window's declaration threads through the startup create. `hide` is supported on macOS and Windows — on Windows it additionally requires the `"tray"` capability (the tray icon is the only re-show affordance there; a declaration without it is refused at build time with a teaching). Linux has no status item to bring a hidden window back, so the declaration is refused at build time with a teaching. See <a href="/docs/windows#close-policy">Windows</a> and the <a href="/docs/tray#the-menu-bar-app-lifecycle">tray lifecycle recipe</a>. The same `close_policy` field is accepted on top-level `windows` entries.
|
||||
|
||||
Supported `kind` values are `webview`, `toolbar`, `titlebar_accessory`, `sidebar`, `statusbar`, `split`, `stack`, `button`, `icon_button`, `list_item`, `checkbox`, `toggle`, `segmented_control`, `text_field`, `search_field`, `label`, `spacer`, `gpu_surface`, and `progress_indicator`.
|
||||
|
||||
Each view has a required `label` and `kind`. WebView views require `url`. Optional layout fields are `parent`, `edge`, `axis`, `x`, `y`, `width`, `height`, `min_width`, `min_height`, `max_width`, `max_height`, `fill`, and `layer`. Optional behavior and accessibility metadata are `visible`, `enabled`, `role`, `accessibility_label`, `text`, and `command`. `gpu_surface` views may also set `gpu_backend`, `gpu_pixel_format`, `gpu_present_mode`, `gpu_alpha_mode`, `gpu_color_space`, and `gpu_vsync`; those fields are rejected on non-GPU view kinds. The currently implemented macOS system-WebView backend uses `metal`, `bgra8_unorm`, `timer`, `opaque`, `srgb`, and `gpu_vsync = true`. `gpu_backend` also accepts `software` (the CPU reference-renderer path); on Linux and Windows system-WebView hosts any declared backend falls back to software presentation rather than erroring. The `axis` field accepts `row` or `column` on parent containers such as `toolbar`, `sidebar`, `split`, and `stack`; it defaults to `row`.
|
||||
Each view has a required `label` and `kind`. WebView views require `url`. Optional layout fields are `parent`, `edge`, `axis`, `x`, `y`, `width`, `height`, `min_width`, `min_height`, `max_width`, `max_height`, `fill`, and `layer`. Optional behavior and accessibility metadata are `visible`, `enabled`, `role`, `accessibility_label`, `text`, and `command`. `gpu_surface` views may also set `gpu_backend`, `gpu_pixel_format`, `gpu_present_mode`, `gpu_alpha_mode`, `gpu_color_space`, and `gpu_vsync`; those fields are rejected on non-GPU view kinds. The macOS system-WebView host presents with Metal, while the Windows host presents representable binary canvas packets with Direct2D/DirectWrite; frame events expose the concrete `metal` or `direct2d` backend. `gpu_backend` accepts `metal` or `software` as portable requests; omit it to select the default. Linux falls back to software presentation, and Windows uses software for unrepresentable commands, transparent layered windows, or when Direct2D is unavailable. The `axis` field accepts `row` or `column` on parent containers such as `toolbar`, `sidebar`, `split`, and `stack`; it defaults to `row`.
|
||||
|
||||
`createShellWindow` and `createShellViews` dock `edge` views against the remaining window content, let one or more top-level `fill` views use the final remaining rectangle, clamp resolved frames with any min/max size fields, and lay out parented controls such as toolbar buttons with small native defaults when explicit `x`, `y`, `width`, or `height` values are omitted. Parent containers flow omitted child positions horizontally with `axis = "row"` and vertically with `axis = "column"`. `split` containers use the same axis without inner spacing, so fixed-size children can sit beside a `fill` child. The runtime keeps the shell view slice as a layout binding and reapplies it when the window is resized, so pass data that lives for the lifetime of the window.
|
||||
|
||||
|
||||
@@ -146,8 +146,8 @@ The runtime watches the command queue and processes these actions:
|
||||
<td>Dispatch a shortcut command event for the main window</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>tray-action <item-id></code></td>
|
||||
<td>Select a status-item dropdown row (ids from the snapshot's <code>tray-item #id</code> lines)</td>
|
||||
<td><code>tray-action <item-id></code> or <code>tray-action <status-item-id> <item-id></code></td>
|
||||
<td>Select a status-item dropdown row. The one-id form targets primary status item <code>#1</code>; multiple-item snapshots print <code>tray #id</code> headers</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>focus <view-label></code></td>
|
||||
|
||||
@@ -49,7 +49,7 @@ Native controls can also bind a `command` when created with `runtime.createView(
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Platform support queries are available through `window.zero.platform.supports(...)` when `js_window_api` is `true`. The command accepts every feature name from `PlatformFeature`: `main_webview`, `child_webviews`, `native_views`, `native_control_commands`, `menus`, `tray`, `shortcuts`, `dialogs`, `clipboard_text`, `clipboard_rich_data`, `open_url`, `reveal_path`, `notifications`, `recent_documents`, `credentials`, `file_drops`, `app_activation_events`, `gpu_surfaces`, `gpu_surface_scroll_drivers`, `context_menus`, `view_surface_adoption`, `audio_playback`, `audio_streaming`, `audio_spectrum`, and `window_hide_on_close`. JavaScript callers can also use the camelCase aliases — `mainWebView`, `childWebViews`, `nativeViews`, `nativeControlCommands`, `clipboardText`, `clipboardRichData`, `openUrl`, `revealPath`, `recentDocuments`, `fileDrops`, `appActivationEvents`, `gpuSurfaces`, `gpuSurfaceScrollDrivers`, `contextMenus`, `viewSurfaceAdoption`, `audioPlayback`, `audioStreaming`, `audioSpectrum`, and `windowHideOnClose`. The helper accepts either a string or a selector object with `feature` or `name`; raw bridge payloads may use the same fields. Use an explicit `builtin_bridge` policy when you want per-command origin lists.
|
||||
Platform support queries are available through `window.zero.platform.supports(...)` when `js_window_api` is `true`. The command accepts every feature name from `PlatformFeature`: `main_webview`, `child_webviews`, `native_views`, `native_control_commands`, `menus`, `tray`, `shortcuts`, `dialogs`, `clipboard_text`, `clipboard_rich_data`, `open_url`, `reveal_path`, `notifications`, `recent_documents`, `credentials`, `file_drops`, `app_activation_events`, `gpu_surfaces`, `gpu_surface_scroll_drivers`, `context_menus`, `view_surface_adoption`, `audio_playback`, `audio_streaming`, `audio_spectrum`, `microphone_capture`, `system_audio_capture`, and `window_hide_on_close`. JavaScript callers can also use the camelCase aliases — `mainWebView`, `childWebViews`, `nativeViews`, `nativeControlCommands`, `clipboardText`, `clipboardRichData`, `openUrl`, `revealPath`, `recentDocuments`, `fileDrops`, `appActivationEvents`, `gpuSurfaces`, `gpuSurfaceScrollDrivers`, `contextMenus`, `viewSurfaceAdoption`, `audioPlayback`, `audioStreaming`, `audioSpectrum`, `microphoneCapture`, `systemAudioCapture`, and `windowHideOnClose`. The helper accepts either a string or a selector object with `feature` or `name`; raw bridge payloads may use the same fields. Use an explicit `builtin_bridge` policy when you want per-command origin lists.
|
||||
|
||||
## Window commands
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Capabilities
|
||||
|
||||
Native SDK capabilities are native OS services and app events exposed through `PlatformServices`, runtime methods, lifecycle events, and — for apps that [embed web content](/docs/frontend) — guarded bridge commands. Native code reaches them directly; web content does not receive capability access by default. In a [`UiApp`](/docs/app-model), clipboard access rides the effects channel (`fx.writeClipboard` / `fx.readClipboard`) so `update` never needs a runtime handle.
|
||||
Native SDK capabilities are native OS services and app events exposed through `PlatformServices`, runtime methods, lifecycle events, and — for apps that [embed web content](/docs/frontend) — guarded bridge commands. Native code reaches them directly; web content does not receive capability access by default. In a [model core](/docs/app-model), OS work rides the effects channel (`Cmd.*` in TypeScript and `fx.*` in Zig) so `update` never needs a runtime handle.
|
||||
|
||||
Web content itself is declare-to-use: an app ships the embedded web layer only when it declares web intent — `"webview"` in `.capabilities`, a `.frontend` block, a `.shell` webview view, or a web engine resolved to Chromium (`.web_engine = "chromium"` in app.zon, or the `-Dweb-engine`/`--web-engine` flags) — and an app that declares none of them builds native-only, where any attempt to create a webview fails with a teaching error instead of loading a layer the app never asked for. The [`webview_layer`](/docs/app-zon) manifest field overrides the inference in either direction. Native-only builds shed the platform web stack for real: the Windows executable carries no `WebView2Loader.dll` reference, and the Linux host neither links WebKitGTK nor requires `libwebkitgtk` on user machines.
|
||||
|
||||
@@ -10,7 +10,7 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Capability</th>
|
||||
<th>Zig API</th>
|
||||
<th>Native API</th>
|
||||
<th>JavaScript command</th>
|
||||
<th>Bridge permission</th>
|
||||
<th>Current native support</th>
|
||||
@@ -19,17 +19,38 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Open URL in system browser</td>
|
||||
<td><code>runtime.openExternalUrl(url)</code></td>
|
||||
<td><code>Cmd.openExternalUrl(url)</code> / <code>runtime.openExternalUrl(url)</code></td>
|
||||
<td><code>native-sdk.os.openUrl</code></td>
|
||||
<td><code>network</code></td>
|
||||
<td>macOS, Linux, and Windows system WebView; macOS Chromium</td>
|
||||
<td>macOS, Linux, and Windows model cores and system WebView; macOS Chromium</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>System notification</td>
|
||||
<td><code>runtime.showNotification(options)</code></td>
|
||||
<td><code>Cmd.showNotification(spec)</code> / <code>fx.showNotification(options)</code> / <code>runtime.showNotification(options)</code></td>
|
||||
<td><code>native-sdk.os.showNotification</code></td>
|
||||
<td><code>notifications</code></td>
|
||||
<td>macOS, Linux, and Windows system WebView; macOS Chromium</td>
|
||||
<td>macOS, Linux, and Windows model cores and system WebView; macOS Chromium</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Model persistence</td>
|
||||
<td><code>Cmd.persist()</code> / <code>fx.persist()</code></td>
|
||||
<td>None. Model-core effect only.</td>
|
||||
<td>None. Gated by the <code>persist</code> build capability.</td>
|
||||
<td>Generated TypeScript app runners on every app-data platform; Zig-core hosts receive the same named <code>core.persist</code> effect seam</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Record store</td>
|
||||
<td><code>Cmd.store.set/get/delete/scan/setMany</code> / <code>fx.storeSet/storeGet/storeDelete/storeScan/storeSetMany</code></td>
|
||||
<td>None. Model-core effect only.</td>
|
||||
<td>None. Gated by the <code>store</code> build capability.</td>
|
||||
<td>SQLite-backed engine store in the per-app data directory; replay remains offline and the core devhost uses a process-local map</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Relational SQLite</td>
|
||||
<td><code>Cmd.q<Name></code> / <code>Cmd.qTx</code> / <code>Sub.q<Name></code> / raw <code>Cmd.db.query/exec</code> / <code>fx.dbQuery/dbExec/dbSubscribe</code></td>
|
||||
<td>None. Model-core effect only.</td>
|
||||
<td>None. Gated by the <code>sqlite</code> build capability.</td>
|
||||
<td>Checked migrations and named SQL over engine-owned <code>app.db</code>; real in-memory SQLite in check, devhost, and tests; journal-only replay</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Native dialogs</td>
|
||||
@@ -40,10 +61,10 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Reveal path in file manager</td>
|
||||
<td><code>runtime.revealPath(path)</code></td>
|
||||
<td><code>Cmd.revealPath(path)</code> / <code>runtime.revealPath(path)</code></td>
|
||||
<td><code>native-sdk.os.revealPath</code></td>
|
||||
<td><code>filesystem</code></td>
|
||||
<td>macOS, Linux, and Windows system WebView; macOS Chromium</td>
|
||||
<td>macOS, Linux, and Windows model cores and system WebView; macOS Chromium</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Recent documents</td>
|
||||
@@ -82,10 +103,17 @@ Web content itself is declare-to-use: an app ships the embedded web layer only w
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Credential store</td>
|
||||
<td><code>runtime.setCredential(options)</code> / <code>runtime.getCredential(key)</code> / <code>runtime.deleteCredential(key)</code></td>
|
||||
<td><code>Cmd.credentials.set/get/delete</code> / <code>fx.credentialsSet/Get/Delete</code> / runtime equivalents</td>
|
||||
<td><code>native-sdk.credentials.set</code> / <code>native-sdk.credentials.get</code> / <code>native-sdk.credentials.delete</code></td>
|
||||
<td><code>credentials</code></td>
|
||||
<td>macOS system WebView and macOS Chromium through Keychain; Linux system WebView through Secret Service/libsecret when available; Windows system WebView through Credential Manager</td>
|
||||
<td><code>credentials</code>. Model cores require both the build capability and permission; WebView commands require the bridge permission.</td>
|
||||
<td>macOS model cores, system WebView, and Chromium through Keychain; Linux model cores and system WebView through Secret Service/libsecret when available; Windows model cores and system WebView through Credential Manager; iOS toolkit apps through generic-password Keychain entries; Android toolkit apps through an AndroidKeyStore AES-GCM key and authenticated ciphertext in app-private preferences. Core dev and test hosts use hermetic memory.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Local date/time formatting</td>
|
||||
<td><code>Cmd.formatLocalTime(timestampMs, style, route)</code> / <code>runtime.formatLocalTime(...)</code></td>
|
||||
<td>None. Effects/runtime API.</td>
|
||||
<td>None. No bridge surface.</td>
|
||||
<td>macOS, Linux, and Windows model cores; locale- and time-zone-aware, with results captured by session recording</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>App activation events</td>
|
||||
@@ -132,6 +160,8 @@ const support = {
|
||||
audioPlayback: await window.zero.platform.supports("audio_playback"),
|
||||
audioStreaming: await window.zero.platform.supports("audio_streaming"),
|
||||
audioSpectrum: await window.zero.platform.supports("audio_spectrum"),
|
||||
microphoneCapture: await window.zero.platform.supports("microphone_capture"),
|
||||
systemAudioCapture: await window.zero.platform.supports("system_audio_capture"),
|
||||
};
|
||||
```
|
||||
|
||||
@@ -220,4 +250,82 @@ window.zero.on("app:activate", () => {
|
||||
});
|
||||
```
|
||||
|
||||
Use the `app.zon` app id as `service` when WebView code and a model core need to address the same entry. Core credential effects apply that namespace automatically; their key corresponds to the bridge `account` field.
|
||||
|
||||
## Model cores
|
||||
|
||||
Model-driven apps request notifications as effects. Delivery is fire-and-forget because OS focus modes and user settings remain authoritative after the host accepts the request; fake-executor tests and session replay do not display notifications.
|
||||
|
||||
### TypeScript
|
||||
|
||||
```ts
|
||||
case "build_finished":
|
||||
return [model, Cmd.showNotification({
|
||||
title: asciiBytes("Build finished"),
|
||||
subtitle: asciiBytes("native-sdk"),
|
||||
body: asciiBytes("All checks passed."),
|
||||
})];
|
||||
```
|
||||
|
||||
### Zig
|
||||
|
||||
```zig
|
||||
.build_finished => fx.showNotification(.{
|
||||
.title = "Build finished",
|
||||
.subtitle = "native-sdk",
|
||||
.body = "All checks passed.",
|
||||
}),
|
||||
```
|
||||
|
||||
Apps using the lower-level runtime can call the same platform seam directly with `try runtime.showNotification(options)`.
|
||||
|
||||
### Credentials
|
||||
|
||||
Credentials are app-scoped effects, not Model data. Declare both gates; `native check` reports NS1071 for a missing capability and NS1072 for a missing permission when a TypeScript core uses `Cmd.credentials.*`. NS1073 reserves the underlying `core.credentials.*` request names for these typed factories:
|
||||
|
||||
```zig:app.zon
|
||||
.capabilities = .{ "credentials" },
|
||||
.permissions = .{ "credentials" },
|
||||
```
|
||||
|
||||
The manifest app id is the OS keychain service namespace and is bounded at 128 bytes, so authored code supplies only a key. Keys are NUL-free UTF-8 through 256 bytes and secrets are bounded at 2,560 bytes, the largest binary value every first-party OS backend can store whole. A get miss routes the error arm with `miss`; the other closed outcomes are `denied`, `locked`, `io_failed`, `over_bound`, and `rejected`. Set and delete route empty bytes on success, and delete is idempotent.
|
||||
|
||||
```ts
|
||||
case "save_token":
|
||||
return [model, Cmd.credentials.set("api-token", msg.token, {
|
||||
key: "save-token",
|
||||
ok: "token_saved",
|
||||
err: "credential_failed",
|
||||
})];
|
||||
case "load_token":
|
||||
return [model, Cmd.credentials.get("api-token", {
|
||||
key: "load-token",
|
||||
ok: "token_loaded",
|
||||
err: "credential_failed",
|
||||
})];
|
||||
case "token_loaded":
|
||||
// Consume msg.token immediately to construct the next effect. Do not
|
||||
// copy it into Model, where persistence and state fingerprints can see it.
|
||||
return [model, Cmd.fetch({
|
||||
url: asciiBytes("https://api.example.com/me"),
|
||||
headers: { authorization: msg.token },
|
||||
}, { ok: "profile_loaded", err: "profile_failed" })];
|
||||
```
|
||||
|
||||
```zig
|
||||
.save_token => |token| fx.credentialsSet(.{
|
||||
.key = 41,
|
||||
.credential_key = "api-token",
|
||||
.secret = token,
|
||||
.on_result = Effects.credentialsMsg(.credential_result),
|
||||
}),
|
||||
.load_token => fx.credentialsGet(.{
|
||||
.key = 42,
|
||||
.credential_key = "api-token",
|
||||
.on_result = Effects.credentialsMsg(.credential_result),
|
||||
}),
|
||||
```
|
||||
|
||||
Credential results still cross the effect boundary, but recording never writes secret bytes to the journal or blob store. A successful recorded get keeps only its length, a per-session salt, and a placeholder digest that is deliberately independent of the secret (so the artifact is not a password-guessing oracle). Replay supplies deterministic placeholder bytes of the same length and never opens the live keychain. Consequently, replay is suitable for control-flow verification but cannot perform a new authenticated exchange with the original token. The core devhost also stores credentials only in process memory and prints `<redacted, N bytes>`.
|
||||
|
||||
See also: [Builtin Commands](/docs/bridge/builtin-commands) and [Security](/docs/security).
|
||||
|
||||
@@ -72,7 +72,7 @@ Run the app's test suite, printing the zig build summary (step/test tally) plus
|
||||
native check [dir] [--strict]
|
||||
```
|
||||
|
||||
Validate the whole tree without building the app. A TypeScript core (`src/core.ts`) runs the subset checker first — real tsc semantics plus the app-core rules, diagnostics verbatim — then every `src/**.native` markup file and `app.zon` are checked as before. With a fresh model contract (`zig-out/model-contract.zon`, refreshed by `native test`) it also checks bindings, iterables, and message tags against your `Model`/`Msg` — for a TypeScript core, against its emitted model — and warns on model state no view uses. Without the artifact it degrades to structural checking and says so loudly: "model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks". Markup accessibility findings are reported per file in full, and a failing `src/*.native` file that no Zig source embeds gets a leftover-file hint.
|
||||
Validate the whole tree without building the app. A TypeScript core (`src/core.ts`) runs the subset checker first — real tsc semantics plus the app-core rules, diagnostics verbatim — then every `src/**.native` markup file and `app.zon` are checked as before. With a fresh model contract (`zig-out/model-contract.zon`, refreshed by `native test`) it also checks bindings, iterables, and message tags against your `Model`/`Msg` — for a TypeScript core, against its model contract — and warns on model state no view uses. Without the artifact it degrades to structural checking and says so: "model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks". Markup accessibility findings are reported per file in full, and a failing `src/*.native` file that no Zig source embeds gets a leftover-file hint.
|
||||
|
||||
<dl>
|
||||
<dt><code>--strict</code></dt>
|
||||
@@ -151,19 +151,20 @@ Package the app for distribution. The manifest is picked up at `app.zon` and the
|
||||
<dt><code>--team-id</code></dt>
|
||||
<dd>Apple Developer Team ID.</dd>
|
||||
<dt><code>--archive</code></dt>
|
||||
<dd>Create a distributable archive.</dd>
|
||||
<dd>Create a distributable archive. On macOS this is a styled DMG with the app, an Applications alias, a generated or custom background, and the Finder layout declared by <code>app.zon</code>.</dd>
|
||||
</dl>
|
||||
|
||||
### Platform shortcuts
|
||||
|
||||
```sh
|
||||
native package-windows [--output path] [--binary path]
|
||||
native package-linux [--output path] [--binary path]
|
||||
native package-windows [--output path] [--binary path] [--service-binary path]
|
||||
native package-linux [--output path] [--binary path] [--service-binary path]
|
||||
native package-ios [--output path] [--binary path]
|
||||
native package-android [--output path] [--binary path]
|
||||
```
|
||||
|
||||
Per-platform shortcuts for `native package --target <platform>`.
|
||||
The desktop shortcuts use an explicit `--service-binary` when supplied; otherwise, service-bearing projects discover the normal `zig-out/bin/<app>_services[.exe]` build output just like the canonical command.
|
||||
|
||||
### `native bundle-assets`
|
||||
|
||||
@@ -237,8 +238,8 @@ Interact with the automation server of a running automation-enabled app. See [Au
|
||||
<dd>Dispatch a trackpad pinch gesture at a gpu-surface view (<code>scale</code> is the final multiplicative zoom for the gesture; the anchor point defaults to the view center).</dd>
|
||||
<dt><code>automate shortcut <id></code></dt>
|
||||
<dd>Dispatch a shortcut command event.</dd>
|
||||
<dt><code>automate tray-action <item-id></code></dt>
|
||||
<dd>Select a status-item dropdown row.</dd>
|
||||
<dt><code>automate tray-action <item-id></code> / <code>automate tray-action <status-item-id> <item-id></code></dt>
|
||||
<dd>Select a status-item dropdown row; the one-id shorthand targets primary status item <code>#1</code>.</dd>
|
||||
<dt><code>automate focus <view-label></code></dt>
|
||||
<dd>Focus a native or WebView-backed view.</dd>
|
||||
<dt><code>automate focus-next</code> / <code>automate focus-previous</code></dt>
|
||||
|
||||
@@ -24,6 +24,27 @@ HTML-family highlighting understands HTML, XML, SVG, JSX, and TSX structure: ele
|
||||
|
||||
`source` is required and must be one `{binding}` producing text. `language` is a literal lexer name; unknown names are validation errors. Line numbers are off by default and remain decorative, so selecting and copying a numbered block returns only the source text. Numbered presentation is limited to 128 logical lines; longer sources keep all code and omit the gutter.
|
||||
|
||||
## Added and removed lines
|
||||
|
||||
Diff presentation follows Geist Code Block in the default and Geist theme packs, across light and dark appearances. `added-lines` and `removed-lines` apply full-width green/red washes and renderer-owned `+`/`-` markers while the underlying source, syntax highlighting, selection, and copied text stay unchanged.
|
||||
|
||||
<ComponentPreview name="code-diff" alt="A JavaScript configuration diff with green added and red removed lines" caption="Geist-style added and removed lines over ordinary JavaScript highlighting" />
|
||||
|
||||
```html
|
||||
<code
|
||||
source="{migration_source}"
|
||||
language="javascript"
|
||||
line-numbers
|
||||
added-lines="5"
|
||||
removed-lines="2-4"
|
||||
wrap="false"
|
||||
width="480"
|
||||
label="Configuration migration"
|
||||
/>
|
||||
```
|
||||
|
||||
Line specs are one-based comma lists and inclusive ranges: `added-lines="5, 9-11"`. They annotate clean source—the `+` and `-` are decoration, not bytes callers must splice into the model. This keeps the selected/copied result usable and lets `language` continue highlighting the real grammar. A line cannot be both added and removed. Diff metadata is bounded to lines 1–128; read-only sources longer than 128 lines keep every source byte and omit the diff treatment.
|
||||
|
||||
For editable code, apply each `TextInputEvent` to the same model-owned buffer that supplies `source`:
|
||||
|
||||
```html
|
||||
@@ -53,16 +74,18 @@ Surface styling belongs to a wrapper:
|
||||
|
||||
```zig
|
||||
ui.code(.{
|
||||
.language = .html,
|
||||
.editable = true,
|
||||
.on_input = Ui.inputMsg(.edit_document),
|
||||
.language = .javascript,
|
||||
.line_numbers = true,
|
||||
.added_lines = &.{5},
|
||||
.removed_lines = &.{ 2, 3, 4 },
|
||||
.wrap = false,
|
||||
.width = 480,
|
||||
.semantics = .{ .label = "Accordion example" },
|
||||
}, model.component_source)
|
||||
.semantics = .{ .label = "Configuration migration" },
|
||||
}, model.migration_source)
|
||||
```
|
||||
|
||||
The editable path uses the same `added_lines` and `removed_lines` options when an editor needs annotations; keep those line numbers synchronized as edits change the document.
|
||||
|
||||
The Zig builder composes the same way when chrome is wanted:
|
||||
|
||||
```zig
|
||||
@@ -79,4 +102,4 @@ Zig; JavaScript and TypeScript; JSX and TSX; JSON; YAML; shell; Python; Rust; C,
|
||||
|
||||
## Attributes
|
||||
|
||||
<AttrTable element="code" attrs={["source", "language", "editable", "on-input", "line-numbers", "wrap", "width", "height", "min-width", "grow", "key", "global-key", "label"]} />
|
||||
<AttrTable element="code" attrs={["source", "language", "editable", "on-input", "line-numbers", "added-lines", "removed-lines", "wrap", "width", "height", "min-width", "grow", "key", "global-key", "label"]} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
# Input Group
|
||||
|
||||
The composer shape: one bordered field wrapping a [textarea](/docs/components/textarea) plus an accessory row of controls inside the same border — attach on the bottom-left, send on the bottom-right. The group wears the focus ring whenever focus is on any control inside it, and the textarea's own chrome dissolves automatically, so the whole group reads as a single field. The textarea keeps its full behavior: `text` and `placeholder` bind from the model, `on-input` hears every edit, `on-submit` rides the primary chord, and `autofocus` lands the keyboard on mount.
|
||||
The composer shape: one bordered field wrapping a [textarea](/docs/components/textarea) plus an accessory row of controls inside the same border — attach on the bottom-left, send on the bottom-right. The group wears the focus ring whenever focus is on any control inside it, and the textarea's own chrome dissolves automatically, so the whole group reads as a single field. The textarea keeps its full behavior: `text` and `placeholder` bind from the model, `on-input` hears every edit, `on-submit` handles submission, `submit-on-enter="true"` opts a chat composer into plain-Enter submission, and `autofocus` lands the keyboard on mount.
|
||||
|
||||
<ComponentPreview name="input-group" alt="An input group rendered by the engine" />
|
||||
|
||||
@@ -13,7 +13,7 @@ The textarea comes first (document order is focus order), then the optional `inp
|
||||
|
||||
```html
|
||||
<input-group label="Message composer" height="120">
|
||||
<textarea placeholder="Type a message" text="{draft}" on-input="draft_edited" on-submit="send" />
|
||||
<textarea placeholder="Type a message" text="{draft}" submit-on-enter="true" on-input="draft_edited" on-submit="send" />
|
||||
<input-group-actions>
|
||||
<button icon="plus" variant="ghost" size="icon" on-press="attach" label="Attach"></button>
|
||||
<spacer grow="1" />
|
||||
@@ -33,6 +33,7 @@ ui.inputGroup(.{
|
||||
}, ui.el(.textarea, .{
|
||||
.placeholder = "Type a message",
|
||||
.text = model.draft,
|
||||
.submit_on_enter = true,
|
||||
.on_input = Ui.inputMsg(.draft_edited),
|
||||
.on_submit = .send,
|
||||
.semantics = .{ .label = "Message" },
|
||||
|
||||
@@ -3,14 +3,14 @@ import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
# Markdown
|
||||
|
||||
Renders a markdown string (a GFM subset, pipe tables included) as native widgets through the same text pipeline as every other component — deterministic layout, selectable text. `source` is required and must be one `{binding}`; the element takes no children. Links dispatch `on-link` with the URL as payload (bare URLs autolink), `<details>` blocks toggle through `on-details` plus a model-owned `details-expanded` flag list, and `#123` references linkify through `issue-link-base`. Fenced blocks lower through the reusable [Code](/docs/components/code) component, so indentation and syntax behavior stay identical.
|
||||
Renders a markdown string (a GFM subset, including pipe tables and safe presentational HTML) as native widgets through the same text pipeline as every other component — deterministic layout, selectable text. `source` is required and must be one `{binding}`; the element takes no children. Applications can pass already-loaded image mappings through `images`, links dispatch `on-link` with the URL as payload (bare URLs autolink), `<details>` blocks toggle through `on-details` plus a model-owned `details-expanded` flag list, and `#123` references linkify through `issue-link-base`. Fenced blocks lower through the reusable [Code](/docs/components/code) component, so indentation and syntax behavior stay identical.
|
||||
|
||||
<ComponentPreview name="markdown" alt="A markdown document rendered by the engine" caption="headings, emphasis, inline code, lists, links, and a code block" />
|
||||
|
||||
## Markup
|
||||
|
||||
```html
|
||||
<markdown source="{release_notes}" on-link="open_link" issue-link-base="https://github.com/native-sdk/native/issues/"></markdown>
|
||||
<markdown source="{release_notes}" images="{markdown_images}" on-link="open_link" issue-link-base="https://github.com/native-sdk/native/issues/"></markdown>
|
||||
```
|
||||
|
||||
## Programmatic construction (Zig)
|
||||
@@ -21,11 +21,33 @@ The builder is the `canvas.markdown` module, parameterized over the app's Msg ty
|
||||
const Md = native_sdk.canvas.markdown.Markdown(Msg);
|
||||
|
||||
Md.view(ui, model.release_notes, .{
|
||||
.images = model.markdown_images,
|
||||
.on_link = Ui.linkMsg(.open_link),
|
||||
.issue_link_base = "https://github.com/native-sdk/native/issues/",
|
||||
})
|
||||
```
|
||||
|
||||
Image discovery writes canonical, entity-decoded URLs into caller-owned bounded storage. Consume each `value()` while that storage is alive, copy accepted sources into the model, and use the same bytes for both `fx.loadImage` and the eventual `ResolvedImage.source` mapping:
|
||||
|
||||
```zig
|
||||
var source_storage: [canvas.markdown.max_markdown_images]canvas.markdown.CollectedImageSource = undefined;
|
||||
const sources = canvas.markdown.collectImageSources(model.release_notes, &source_storage);
|
||||
for (sources) |*collected| {
|
||||
const source = collected.value();
|
||||
// Validate the scheme, copy source into the model, then issue fx.loadImage.
|
||||
}
|
||||
```
|
||||
|
||||
## HTML subset
|
||||
|
||||
README- and comment-style HTML is lowered onto native presentation rather than passed to a browser:
|
||||
|
||||
- Text: `<b>`, `<strong>`, `<i>`, `<em>`, `<var>`, `<s>`, `<strike>`, `<del>`, `<u>`, `<ins>`, `<code>`, `<kbd>`, `<samp>`, `<tt>`, `<mark>`, `<small>`, `<sub>`, and `<sup>`.
|
||||
- Content: `<a href>`, `<img src alt width height>`, `<br>`, `<wbr>`, `<q>`, HTML comments, core named entities, and numeric entities. A leading image in a paragraph, heading, list item, blockquote, or pipe-table cell becomes a native image when `images` contains a successful `canvas.markdown.ResolvedImage` mapping for its source; unresolved and mid-paragraph images fall back to alt text. The view never fetches remote media — discover bounded sources with `canvas.markdown.collectImageSources`, read each caller-owned `CollectedImageSource` through `value()`, load it through `fx.loadImage`, retain successful ids and dimensions in the model, and pass mappings with that canonical source back on the next rebuild.
|
||||
- Blocks and wrappers: `<h1>` through `<h6>`, `<p>`, `<blockquote>`, `<pre>`, `<hr>`, list items, and the common `<div align="center">` README pattern. Harmless list, table, and container wrappers are accepted as readable native content; use Markdown pipe tables for full native table layout. `<details>` and `<summary>` retain their controlled expansion behavior.
|
||||
|
||||
This is a sanitized native subset, not a DOM. CSS, scripts, forms, embeds, event attributes, and arbitrary HTML do not execute; unsupported or malformed tags remain literal text. Only `href`, image `src`/`alt`/dimensions, and block `align` affect presentation.
|
||||
|
||||
## Attributes
|
||||
|
||||
<AttrTable element="markdown" attrs={["source", "on-link", "on-details", "details-expanded", "issue-link-base"]} />
|
||||
<AttrTable element="markdown" attrs={["source", "images", "on-link", "on-details", "details-expanded", "issue-link-base"]} />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
|
||||
|
||||
# Scroll
|
||||
|
||||
A scroll view: wrap multiple children in a single column inside it. The engine owns wheel, kinetic, and keyboard scrolling and draws the scrollbar while a scroll is in flight; `on-scroll` names a Msg variant with a `canvas.ScrollState` payload — or, in a transpiled TypeScript core, a declared record of the same two-axis fields (`offset_x`/`offset_y`, `velocity_x`/`velocity_y`, `viewport_extent_x`/`viewport_extent_y`, `content_extent_x`/`content_extent_y`), matched by name — that delivers the post-scroll offsets and viewport/content extents on both axes, so the model can observe position without owning it. Echo `offset_y` into a model field bound as `value` and the model owns the position too: setting the field scrolls the region (the controlled-scroll shape).
|
||||
A scroll view: wrap multiple children in a single column inside it. The engine owns wheel, kinetic, and keyboard scrolling and draws the scrollbar while a scroll is in flight; `on-scroll` names a Msg variant with a `canvas.ScrollState` payload — or, in a compiled TypeScript core, a declared record of the same two-axis fields (`offset_x`/`offset_y`, `velocity_x`/`velocity_y`, `viewport_extent_x`/`viewport_extent_y`, `content_extent_x`/`content_extent_y`), matched by name — that delivers the post-scroll offsets and viewport/content extents on both axes, so the model can observe position without owning it. Echo `offset_y` into a model field bound as `value` and the model owns the position too: setting the field scrolls the region (the controlled-scroll shape).
|
||||
|
||||
`axis` declares which axes the region scrolls: `vertical` (the default), `horizontal`, or `both`. A horizontal grant opts the region into wheel/trackpad `delta_x`, a bottom-edge scrollbar, and the horizontal keymap — Left/Right step in lines, and a horizontal-only region takes Home/End/PageUp/PageDown on its one axis too. The horizontal offset rides `value-x`, the sideways counterpart of `value` with the same source-wins reconcile. Nested regions route each wheel axis independently: every axis of a scroll gesture travels to the nearest ancestor that scrolls on that axis, so inside a horizontal timeline holding a vertical list, `delta_y` scrolls the list while `delta_x` reaches the timeline — one diagonal gesture, two regions, no fighting. Virtualized scrolls stay vertical (windowed virtualization prices rows, not columns). Scrolling pins at the content edges by default — no rubber-band bounce; kinetic motion stops cleanly at the boundary. `overscroll="rubber_band"` opts one region into bouncing past its edges (both the engine physics and the native macOS scroller honor it), and the `ScrollPhysics.overscroll` design token flips the app-wide default, which per-region values override. `on-reach-end` dispatches a plain Msg when a scroll comes within one viewport of the content end — the infinite-fetch signal, fired once per approach with hysteresis (appending a batch grows the extent and re-arms the next approach). A programmatic jump to the end fires once and never re-arms while the offset stays near the end — re-arming needs a post-scroll observation at least 1.5 viewports from it. Pair with [list](/docs/components/list) for layout-culled rows, or the builder's [virtual list](/docs/components/virtual-list) for dataset-scale windows.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
|
||||
|
||||
# Slider
|
||||
|
||||
A draggable value control. The model owns `value` as a 0..1 fraction and renders it back on every rebuild; `on-change` dispatches when the user moves the thumb (drag, keyboard step, or accessibility set-value). In markup, `on-change` resolves by the named Msg arm's shape: a bare tag naming a VALUE arm — `f32`, or a transpiled core's one-number float arm — dispatches the applied fraction as its payload (the seek-bar shape), while a bare tag naming a void arm stays the plain "something changed" signal. Either way, `update` echoes the delivered value back into the model field. Reconcile follows the scroll rule: a source-side move wins (a model-driven value — playback progress on a seek bar — renders every rebuild), a source replaying the same value keeps the user's drag, and a live drag is never yanked mid-gesture. For a display-only bar, use [progress](/docs/components/progress).
|
||||
A draggable value control. The model owns `value` as a 0..1 fraction and renders it back on every rebuild; `on-change` dispatches when the user moves the thumb (drag, keyboard step, or accessibility set-value). In markup, `on-change` resolves by the named Msg arm's shape: a bare tag naming a VALUE arm — `f32`, or a compiled core's one-number float arm — dispatches the applied fraction as its payload (the seek-bar shape), while a bare tag naming a void arm stays the plain "something changed" signal. Either way, `update` echoes the delivered value back into the model field. Reconcile follows the scroll rule: a source-side move wins (a model-driven value — playback progress on a seek bar — renders every rebuild), a source replaying the same value keeps the user's drag, and a live drag is never yanked mid-gesture. For a display-only bar, use [progress](/docs/components/progress).
|
||||
|
||||
<ComponentPreview name="slider" alt="Sliders rendered by the engine" caption="a bound slider and a disabled slider" />
|
||||
|
||||
|
||||
@@ -11,14 +11,23 @@ A tab strip: ONE muted rounded container (the house tab-strip treatment) whose t
|
||||
|
||||
```html
|
||||
<column gap="16">
|
||||
<tabs>
|
||||
<button selected="{tab == account}" on-press="show_account">Account</button>
|
||||
<button selected="{tab == password}" on-press="show_password">Password</button>
|
||||
<button selected="{tab == team}" on-press="show_team">Team</button>
|
||||
</tabs>
|
||||
<if test="{tab == account}">
|
||||
<row>
|
||||
<tabs>
|
||||
<button selected="{tab == 'account'}" on-press="show_account">Account</button>
|
||||
<button selected="{tab == 'password'}" on-press="show_password">Password</button>
|
||||
<button selected="{tab == 'team'}" on-press="show_team">Team</button>
|
||||
</tabs>
|
||||
<spacer grow="1" />
|
||||
</row>
|
||||
<if test="{tab == 'account'}">
|
||||
<text foreground="text_muted">Manage your account details.</text>
|
||||
</if>
|
||||
<if test="{tab == 'password'}">
|
||||
<text foreground="text_muted">Change your password.</text>
|
||||
</if>
|
||||
<if test="{tab == 'team'}">
|
||||
<text foreground="text_muted">Manage your team.</text>
|
||||
</if>
|
||||
</column>
|
||||
```
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
# Textarea
|
||||
|
||||
Multi-line text entry. Like [input](/docs/components/input), `text` and `placeholder` bind from the model and `on-input` names a Msg variant that receives every edit as a text-input event — see [input](/docs/components/input) for the core-side contract in both languages. Enter (and Shift+Enter) inserts a newline instead of submitting; when a textarea carries `on-submit`, the submit rides the primary chord — Cmd+Enter on macOS, Ctrl+Enter elsewhere. Give it a definite `width` and `height` (or a `grow`) to size the editing box.
|
||||
Multi-line text entry. Like [input](/docs/components/input), `text` and `placeholder` bind from the model and `on-input` names a Msg variant that receives every edit as a text-input event — see [input](/docs/components/input) for the core-side contract in both languages. By default, Enter (and Shift+Enter) inserts a newline; when a textarea carries `on-submit`, submission rides Cmd+Enter on macOS or Ctrl+Enter elsewhere. Chat composers can set `submit-on-enter="true"`: plain Enter then submits, Shift+Enter still inserts a newline, and the primary chord still submits. Give it a definite `width` and `height` (or a `grow`) to size the editing box.
|
||||
|
||||
<ComponentPreview name="textarea" alt="A textarea rendered by the engine" />
|
||||
|
||||
@@ -35,6 +35,7 @@ ui.el(.textarea, .{
|
||||
"placeholder",
|
||||
"disabled",
|
||||
"autofocus",
|
||||
"submit-on-enter",
|
||||
"on-input",
|
||||
"on-submit",
|
||||
]}
|
||||
|
||||
@@ -80,7 +80,7 @@ One honest interaction with the [tofu guard](/docs/native-ui#tooling): the stati
|
||||
|
||||
## Platform truth
|
||||
|
||||
The registration pipeline — TTF parsing, glyph outlines, rasterization — is the SDK's own code, identical on every platform. macOS additionally registers the face with the host so CoreText-side measurement and Metal packet text agree with it. Windows and Linux present the deterministic software renderer, which inks registered outlines directly; the suite pins pixel parity between the present path and the reference path for a registered face, and a CI receipt registers a CJK face and proves Chinese text renders as real glyphs on a native Windows runner. Mobile hosts render the same reference-renderer pixels, but no mobile test exercises registered fonts yet and the mobile hosts' text measurement has no registered-font seam — treat registered fonts on iOS and Android as unverified today. See [Platform Support](/docs/platform-support#support-matrix) for the per-platform matrix.
|
||||
The registration pipeline — TTF parsing, glyph outlines, rasterization — is the SDK's own code, identical on every platform. macOS additionally registers the face with CoreText so measurement and Metal packet text agree with it; Windows registers the same bytes with DirectWrite for its retained packet path. Linux and Windows software fallback ink the registered outlines directly. The suite pins reference-renderer pixel behavior for a registered face, and a CI receipt registers a CJK face and proves Chinese text renders as real glyphs on a native Windows runner. Mobile hosts render the same reference-renderer pixels, but no mobile test exercises registered fonts yet and the mobile hosts' text measurement has no registered-font seam — treat registered fonts on iOS and Android as unverified today. See [Platform Support](/docs/platform-support#support-matrix) for the per-platform matrix.
|
||||
|
||||
## The TypeScript tier
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Native surfaces are the app-owned regions that the Native SDK composes inside a
|
||||
<tr>
|
||||
<td>GPU surface</td>
|
||||
<td>The canvas your <a href="/docs/native-ui">native UI</a> renders into — plus custom drawing regions for editors, timelines, dashboards, and games</td>
|
||||
<td>Metal-backed on macOS; software (CPU reference renderer) presentation on Linux and Windows system hosts</td>
|
||||
<td>Metal-backed on macOS; retained Direct2D/DirectWrite on Windows; CPU reference-rendered on Linux and as the Windows fallback</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Native chrome</td>
|
||||
@@ -161,7 +161,7 @@ Existing `App.source`, `runtime.createWebView(...)`, and `window.zero.webviews.*
|
||||
|
||||
## Platform Support
|
||||
|
||||
macOS, Linux, and Windows system hosts support native views and controls today. The macOS host presents `gpu_surface` through a Metal-backed child view; Linux and Windows present it through the software reference renderer. See `examples/gpu-surface` for a minimal native controls plus WebView plus GPU surface composition. Chromium hosts and mobile hosts expose the same public vocabulary where useful, but unsupported operations reject explicitly instead of silently falling back.
|
||||
macOS, Linux, and Windows system hosts support native views and controls today. The macOS host presents `gpu_surface` through Metal, Windows presents retained representable packets through Direct2D/DirectWrite with an exact software fallback, and Linux presents through the software reference renderer. See `examples/gpu-surface` for a minimal native controls plus WebView plus GPU surface composition. Chromium hosts and mobile hosts expose the same public vocabulary where useful, but unsupported operations reject explicitly instead of silently falling back.
|
||||
|
||||
Use support checks before showing optional native affordances:
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ A few widget kinds are deliberately **not** markup elements because their shape
|
||||
|
||||
Apps with their own iconography can parse any stroke-dialect/Feather/Tabler-dialect SVG at comptime (`canvas.svg_icon.parseComptime(@embedFile("icons/logo.svg"))`) and register it at boot with `canvas.icons.registerAppIcons(&table)`: the draw paths (icon leaves via `ui.appIcon`, `ElementOptions.icon` on buttons, toggle buttons, icon buttons, and list/menu items) resolve registered names exactly like built-ins. Markup reaches them through the `app:` NAMESPACE (`<icon name="app:waveform"/>`, `icon="app:waveform"`): bare names keep the closed built-in vocabulary (the compiled engine proves them at comptime, where a runtime registration cannot exist), while `app:` names are structurally accepted by both engines and verified by `native check` against the model contract - declare the table as `pub const app_icons` on the app root so the contract emit reflects the same names `main` registers. Bound icon names (`icon="{binding}"`) make the choice model data - a per-row status icon, a play/pause toggle - and any name that fails to resolve at draw time renders the missing-icon fallback (a slashed circle) with a Debug warning naming the value, never a silent gap.
|
||||
|
||||
Layout attributes: `gap`, `padding`, `grow`, `width`, `height`, `wrap`, `text-alignment`, `columns`, `main`, `cross`, `virtualized`, and the anchored-floating family on `dropdown-menu` and `tooltip`: `anchor` (`below`/`above` — floats the surface against its parent, flipping when the preferred side does not fit), `anchor-alignment` (`start`/`end`/`stretch`), `anchor-offset` (points, default 4), plus `tooltip-delay` on `tooltip` alone (the hover-intent show delay in milliseconds, default the 600ms token; `"0"` shows the instant the trigger is hovered; keyboard-focus reveals are always immediate — a teaching error without `anchor` beside it). An anchored tooltip's visibility is runtime-owned hover intent on its trigger, unlike the model-owned dropdown. `gap` belongs to flow containers: the stacking kinds (`stack`, `panel`, `card`, and the surface/modal elements) layer their children, so `gap` there is rejected with a teaching error — wrap the children in a `column` (or `row`) inside for flow (on `split` it sets the divider band thickness). `width` and `height` are definite sizes: the element is exactly that size, so intrinsic content neither shrinks nor silently overflows it (`resizable` treats `width` as its initial width), and debug builds log a `zero_canvas_layout` diagnostic whenever children's minimum sizes overflow their container. `min-width` is a floor without the definite max: the element may grow past it but never shrink below — on split panes it is what bounds the divider drag. `wrap` applies to `text` only: `wrap="true"` word-wraps the content at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are the honest single-line mode — the content measures and paints as one line, and content that does not fit follows `overflow`. `overflow` (`text` only) names the single-line policy for content that does not fit: `ellipsis` (the default) elides the tail behind a trailing … measured with the same metrics paint uses, the right choice for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column, where "1…" would be worse than a partial glyph. There is deliberately no overflow-visible — painting past the frame is the bug class the layout audit exists to catch. `text-alignment` (`start`|`center`|`end`) aligns text content in text leaves, status bars, and surface titles. `columns` fixes a `grid`'s column count (grid-only — anywhere else it is a teaching error; omit it for the derived near-square grid). `tree-level` gives flat sibling rows with `role="treeitem"` a one-based logical depth so Left/Right can resolve parents and children; omit it for structurally nested rows. Appearance: `variant`, `size` (the control scale `default`|`sm`|`lg`|`icon` on every sized element; on `text` also the typography rungs `heading`|`display` — named typography token steps for section headings and hero stats, themable like every token, and text-only: on a control they are a teaching error naming text as their home; numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `expanded` (tree rows: model-owned disclosure state; omit on leaves). Focus: `autofocus` (focusable controls only) moves keyboard focus to the element when it mounts or when the bound value turns on — edge-triggered, so holding it true never re-steals focus; it is the model-driven way to focus an editor on create or give a keyboard-first app its first focus. Semantics: `role` (`treeitem` also makes a row part of its tree's roving focus set), `label` (an explicit accessible name — it replaces the element's text; see [Accessibility](#accessibility)). Identity: `key` (sibling-scoped) and `global-key` (survives moving between containers — board cards, tab pages). Window chrome: `window-drag="true"` marks the element as a window-drag surface for hidden-titlebar windows (see [Hidden titlebar](#hidden-titlebar-drag-regions-and-chrome-insets)).
|
||||
Layout attributes: `gap`, `padding`, `grow`, `width`, `height`, `min-width`, `max-width`, `wrap`, `text-alignment`, `columns`, `main`, `cross`, `virtualized`, and the anchored-floating family on `dropdown-menu` and `tooltip`: `anchor` (`below`/`above` — floats the surface against its parent, flipping when the preferred side does not fit), `anchor-alignment` (`start`/`end`/`stretch`), `anchor-offset` (points, default 4), plus `tooltip-delay` on `tooltip` alone (the hover-intent show delay in milliseconds, default the 600ms token; `"0"` shows the instant the trigger is hovered; keyboard-focus reveals are always immediate — a teaching error without `anchor` beside it). An anchored tooltip's visibility is runtime-owned hover intent on its trigger, unlike the model-owned dropdown. `gap` belongs to flow containers: the stacking kinds (`stack`, `panel`, `card`, and the surface/modal elements) layer their children, so `gap` there is rejected with a teaching error — wrap the children in a `column` (or `row`) inside for flow (on `split` it sets the divider band thickness). `width` and `height` are definite sizes: the element is exactly that size, so intrinsic content neither shrinks nor silently overflows it (`resizable` treats `width` as its initial width), and debug builds log a `zero_canvas_layout` diagnostic whenever children's minimum sizes overflow their container. `min-width` is a floor without the definite max: the element may grow past it but never shrink below — on split panes it is what bounds the divider drag. `max-width` is the inverse ceiling without a definite minimum, so the element still shrinks with a narrow parent; a growing child inside a centered row is the responsive content-column pattern. `wrap` applies to `text` only: `wrap="true"` word-wraps the content at the width the element receives and reserves the wrapped height in columns; `wrap="false"` and unset are the honest single-line mode — the content measures and paints as one line, and content that does not fit follows `overflow`. `overflow` (`text` only) names the single-line policy for content that does not fit: `ellipsis` (the default) elides the tail behind a trailing … measured with the same metrics paint uses, the right choice for width-constrained list-row titles; `clip` hard-cuts at the frame for fixed-format content like a duration column, where "1…" would be worse than a partial glyph. There is deliberately no overflow-visible — painting past the frame is the bug class the layout audit exists to catch. `text-alignment` (`start`|`center`|`end`) aligns text content in text leaves, status bars, and surface titles. `columns` fixes a `grid`'s column count (grid-only — anywhere else it is a teaching error; omit it for the derived near-square grid). `tree-level` gives flat sibling rows with `role="treeitem"` a one-based logical depth so Left/Right can resolve parents and children; omit it for structurally nested rows. Appearance: `variant`, `size` (the control scale `default`|`sm`|`lg`|`icon` on every sized element; on `text` also the typography rungs `heading`|`display` — named typography token steps for section headings and hero stats, themable like every token, and text-only: on a control they are a teaching error naming text as their home; numeric sizes are refused by design — retheme the typography tokens to move the whole scale), `disabled`, `checked`, `selected`, `expanded` (tree rows: model-owned disclosure state; omit on leaves). Focus: `autofocus` (focusable controls only) moves keyboard focus to the element when it mounts or when the bound value turns on — edge-triggered, so holding it true never re-steals focus; it is the model-driven way to focus an editor on create or give a keyboard-first app its first focus. Semantics: `role` (`treeitem` also makes a row part of its tree's roving focus set), `label` (an explicit accessible name — it replaces the element's text; see [Accessibility](#accessibility)). Identity: `key` (sibling-scoped) and `global-key` (survives moving between containers — board cards, tab pages). Window chrome: `window-drag="true"` marks the element as a window-drag surface for hidden-titlebar windows (see [Hidden titlebar](#hidden-titlebar-drag-regions-and-chrome-insets)).
|
||||
|
||||
## Styling with design tokens
|
||||
|
||||
@@ -197,13 +197,13 @@ Type discipline is teaching errors over silent coercion: a string minus a number
|
||||
|
||||
The function library is closed — seventeen functions, and growing the set is a toolkit change: `fixed(x, digits)` (exact decimals), `thousands(n)` (`1,234,567`), `percent(fraction, digits?)` (`0.42` → `42%`), `date(ts)`/`time(ts)`/`datetime(ts)` (a model unix timestamp in seconds, formatted in UTC — formatting model time is pure; *reading the clock* is an effect, so `now()` is a teaching error pointing at the model/fx loop), `upper`/`lower`/`trim` (ASCII case mapping; other characters pass through), `min`/`max`/`abs`, `round`/`floor`/`ceil` (number → whole number), `plural(count, singular, plural)` (`{plural(n, 'item', 'items')}`), and `pad(x, width)` (zero-pads the integer value of x to `width` digits — `pad(7, 2)` → `07`, a negative sign precedes the zeros without counting toward the width, and numbers wider than `width` print in full; the mm:ss counter function: `{pad(minutes, 2)}:{pad(seconds, 2)}`).
|
||||
|
||||
Complexity is bounded and taught one past the bound: at most 256 bytes, 64 terms, and 16 nesting levels per expression — anything larger is a named model function by design.
|
||||
Complexity is bounded and taught one past the bound: at most 256 bytes, 64 terms, and 16 nesting levels per expression — anything larger is a named core helper by design.
|
||||
|
||||
The same line separates inline arithmetic from model functions. Inline expression arithmetic is sanctioned for one-off presentation-level derivation — `{percent(done / total)}` on the single readout that shows it is exactly what expressions are for. The moment a derivation is reused in a second binding, deserves a name, or carries meaning the model owns (a threshold, a rule, a policy), it belongs in a named model function: `{completionRate}` reads at the binding site, tests in Zig, and changes in one place.
|
||||
The same line separates inline arithmetic from core helpers. Inline expression arithmetic is sanctioned for one-off presentation-level derivation — `{percent(done / total)}` on the single readout that shows it is exactly what expressions are for. The moment a derivation is reused in a second binding, deserves a name, or carries meaning the model owns (a threshold, a rule, a policy), it belongs in a named helper: `{completionRate}` reads at the binding site, tests in the core's language, and changes in one place.
|
||||
|
||||
Bindings resolve against your model: struct fields, zero-argument public methods, and — for `for each` — slices, public array declarations, or functions taking `(*const Model)` or `(*const Model, std.mem.Allocator)` (the allocator variant is how filtered lists work). Enums resolve to their tag names. Expressions are allowed in text interpolation, attribute values, `if` tests, and template args at use sites; message tags and payloads, `for each` iterables, and import paths stay path-only.
|
||||
In the default TypeScript core, model fields bind by their authored names (`nextId` is `{nextId}`), and an exported helper declared in `src/core.ts` with exactly one `Model` parameter becomes a derived binding under its exported name. Scalar-returning helpers drive text/attributes; array-returning helpers drive `for each` and chart series; item record fields continue the path. See [TypeScript Cores](/docs/typescript) for the supported return shapes and module boundary.
|
||||
|
||||
Scalar bindings take the allocator form too: `{summary}` binds `pub fn summary(m: *const Model, arena: std.mem.Allocator) []const u8` directly, formatting a derived display string into the build arena — it works in text interpolation, attribute values, message payloads, and as function arguments (`{upper(summary)}`). The one exclusion is comparison operands (`==`, `<`, ...), which reject arena-computed values with a teaching error: compare the source fields, or bind a `bool`-returning method. For `<if test>`, write the predicate out (`test="{count > 0}"`) or bind one (`test="{hasItems}"`) instead of leaning on numeric truthiness.
|
||||
In a Zig core, bindings resolve struct fields and zero-argument public model methods. A `for each` iterable may also be a slice, public array declaration, `pub fn (*const Model) []const T`, or `pub fn (*const Model, std.mem.Allocator) []const T`; the allocator form derives filtered rows into the one-build arena. Scalar bindings take that form too: `{summary}` binds `pub fn summary(m: *const Model, arena: std.mem.Allocator) []const u8`. Arena-computed values work in text, attributes, message payloads, and function arguments, but not as comparison operands — compare source fields or bind a boolean helper. In either tier, write `<if test>` predicates explicitly (`test="{count > 0}"` or `test="{hasItems}"`) instead of leaning on numeric truthiness. Expressions are allowed in text interpolation, attribute values, `if` tests, and template args at use sites; message tags and payloads, `for each` iterables, and import paths stay path-only.
|
||||
|
||||
## Messages
|
||||
|
||||
@@ -238,6 +238,8 @@ pub fn draft(model: *const Model) []const u8 {
|
||||
|
||||
`on-resize` (on the `split` element; `Ui.valueMsg(.tag)` on `on_resize` in Zig views) names a variant whose payload is the new first-pane fraction (`f32`): after every divider drag, keyboard adjustment, or assistive increment/decrement the runtime delivers the fraction it already applied and clamped — store it in the model and echo it back through the split's `value`, and rebuilds never fight live resizing.
|
||||
|
||||
`on-drag` makes any element a draggable spatial object and names the closed record `{ sourceId, phase, x, y, viewWidth, viewHeight }`. Markup supplies numeric `sourceId` from a binding such as `on-drag="card_dragged:{card.id}"`; the runtime supplies `phase` (a number able to represent 0, 1, and 2) plus floating-point view-local `x`, `y`, `viewWidth`, and `viewHeight` (`f32`/`f64` in Zig, `number` in TypeScript). Geometry stays floating-point because pointer capture can carry negative coordinates outside the view. Phase 0 means motion, 1 release, and 2 cancellation. The renderer lifts the actual source appearance under the pointer at full opacity and leaves its in-flow space blank. Apps that need precise insertion can keep committed data unchanged during phase 0 while returning a derived view that moves the same `global-key` into the candidate position. Its hidden in-flow rendering is the one card-sized reserved slot: it begins at the source, moves to each candidate, and never duplicates. Keyed draggable neighbors ease between candidate poses; release carries the floating item from its pointer position into that slot and commits the exact order. A plain Escape during the drag dispatches phase 2, consumes that key, and carries the item back to its source slot; pointer cancellation uses the same path. Reduced-motion appearances snap these reflows. `examples/kanban` demonstrates within-column and cross-column reordering with this pattern.
|
||||
|
||||
`on-dismiss` (on the dismissible surfaces: `dialog`, `drawer`, `sheet`, `dropdown-menu`; `ElementOptions.on_dismiss` in Zig views) dispatches when Escape or a click outside dismisses the surface, so the model owns the close — clear the open flag in `update`. The engine hides the surface immediately as an optimistic echo; the next rebuild's source tree is truth. Escape works regardless of focus: it dismisses the nearest surface up the focused widget's chain, and when nothing relevant is focused — a menu opened from a plain-text trigger takes no focus — it falls back to the topmost mounted anchored surface. `on-hold` (any element; `ElementOptions.on_hold`) is press-and-hold: a pointer held ~350 ms dispatches the hold Msg and the release presses nothing, a quick click dispatches `on-press` as usual, and a right/ctrl-click with no context menu on its route dispatches the hold Msg immediately (a declared `<context-menu>` always wins the right-click) — the crumb-switcher shape (`on-press` selects, `on-hold` opens an anchored menu). Both legs are live-drivable through automation: `native automate widget-hold <view> <id>` runs the pointer+timer gesture, `widget-context-press <view> <id>` the secondary click.
|
||||
|
||||
`on-hover-enter` and `on-hover-leave` (any element; `ElementOptions.on_hover_enter` / `on_hover_leave`) are the pointer-hover pair — Elm's `onMouseEnter`/`onMouseLeave`: enter dispatches once when the pointer enters the element's hit region, leave once when it exits — discrete containment edges, never per-move, so hover previews, prefetch, and hover cards are ordinary Msgs. Binding either makes the element hover-hittable the way a bound press makes it pressable (so both are legal everywhere, and a pointer over plain text or icons inside counts as inside), but never pressable: clicks keep falling through, no accessibility action is announced, and no hover wash appears — the wash stays the visual channel of acting controls ([state washes](/docs/building-components#theming-your-component)), so a `quiet-hover` content tile that binds hover stays visually quiet while the model hears it. Nested bound elements track containment independently (moving onto a bound row inside a bound card never leaves the card); enters fire outermost-first and leaves innermost-first. Every enter is answered by exactly one eventual leave while the app runs: the leave Msg is captured while the element stands — kept fresh as rebuilds change the binding, retained from the last build that bound one — so it still arrives when the exit is the element unmounting. Exits follow the same resolution the hover wash already uses: moving off and the pointer leaving the window are direct edges; content scrolling or reflowing out from under a stationary pointer re-hit-tests the last pointer position (a scrolled list fires the same leave and enter a real move would); and a dismissal removing the surface under the pointer delivers that surface's leaves immediately, with whatever it reveals entered when the model's close rebuild re-hit-tests — pair dismissible surfaces with `on-dismiss`, as always. Overlays occlude hover exactly as they occlude clicks (the topmost surface under the pointer wins). Hover comes from mouse and trackpad pointers only: containment advances on hover-phase motion — a pointer floating without contact, which touch physically cannot produce — so touch input never synthesizes hover, and anything reachable only by hover must stay reachable another way.
|
||||
@@ -305,6 +307,21 @@ export function pinchMsg(pinch: PinchEvent): Msg | null {
|
||||
|
||||
macOS emits pinch today (trackpad `magnifyWithEvent:`); Windows precision-touchpad and GTK gesture sources are staged follow-ups — on those platforms the channel simply never fires. Everything flows from the journaled input events, so recorded sessions replay the identical zoom, and tests (or agents without a trackpad) drive the real event stream with `native automate widget-pinch <view-label> <scale> [x y]` — `<scale>` is the final multiplicative zoom for the gesture (one change event whose product lands exactly on it), anchor point defaulting to the view center.
|
||||
|
||||
## File drops
|
||||
|
||||
Native file drops reach a Zig `UiApp` through `Options.on_drop`, after any targeted canvas-widget drop handler. The event carries the source `window_id`, `view_label`, an optional view-local `point`, and every dropped path. TypeScript cores export the same channel as `dropMsg`; import its structural record from `@native-sdk/core/events`. Paths are `Uint8Array` byte text so non-ASCII filesystem names cross without a JS string conversion:
|
||||
|
||||
```ts
|
||||
import { type FileDropEvent } from "@native-sdk/core/events";
|
||||
|
||||
export function dropMsg(drop: FileDropEvent): Msg | null {
|
||||
if (drop.paths.length === 0) return null;
|
||||
return { kind: "file_opened", path: drop.paths[0] };
|
||||
}
|
||||
```
|
||||
|
||||
The platform event is journaled before either route, so record/replay delivers the identical source, point, and path bytes. A host that cannot resolve the target view leaves `viewLabel` empty and `point` null.
|
||||
|
||||
## Native scrolling and context menus
|
||||
|
||||
On macOS, every non-virtualized `scroll` region is driven by an invisible `NSScrollView`: momentum and the system overlay scrollbar are OS-computed while the engine renders the content. This needs no app code — scroll offsets stay on the widget (`sync`, snapshot offsets, and programmatic scrolls work unchanged), and the engine's drawn scrollbar stands down for natively driven regions. Other platforms keep the engine's wheel physics.
|
||||
@@ -432,6 +449,29 @@ Files ride the same channel: `fx.writeFile` / `fx.readFile` persist app state
|
||||
.saved => |result| model.noteSaved(result.outcome),
|
||||
```
|
||||
|
||||
Desktop notifications use the same bounded platform-services facade but are intentionally fire-and-forget: once the host accepts a request, the OS can still suppress it through Focus / Do Not Disturb or the user's notification settings, so a success Msg would over-promise. Return `Cmd.showNotification` from a TypeScript core or call `fx.showNotification` from a Zig `update_fx` arm; invalid or over-bound fields and unavailable services fail closed. The fake executor and session replay never display one.
|
||||
|
||||
<CodeToggle>
|
||||
|
||||
```ts
|
||||
case "build_finished":
|
||||
return [model, Cmd.showNotification({
|
||||
title: asciiBytes("Build finished"),
|
||||
subtitle: asciiBytes("native-sdk"),
|
||||
body: asciiBytes("All checks passed."),
|
||||
})];
|
||||
```
|
||||
|
||||
```zig
|
||||
.build_finished => fx.showNotification(.{
|
||||
.title = "Build finished",
|
||||
.subtitle = "native-sdk",
|
||||
.body = "All checks passed.",
|
||||
}),
|
||||
```
|
||||
|
||||
</CodeToggle>
|
||||
|
||||
Failure and overflow are always visible: a spawn that cannot run delivers an exit Msg with reason `rejected`, a fetch that cannot run delivers a response Msg with outcome `rejected`, and a file effect that cannot run delivers a result Msg with outcome `rejected`; dropped or truncated lines carry counts and flags; `cancel` kills and reaps the process and always ends in exactly one `cancelled` exit Msg, with no further line Msgs after it. Tests use the fake executor (`effects.executor = .fake`) to assert on spawn, fetch, and file requests and feed synthetic lines, stderr (`feedStderr`, collect spawns), exits, responses, and file results back deterministically — set it before the first frame and `init_fx` boot spawns are recorded too. See `examples/effects-probe`.
|
||||
|
||||
For timestamps, the facade owns the clocks (Zig 0.16 puts `std.time` behind `std.Io`, which `update` never sees): `native_sdk.nowMs()` / `nowNanoseconds()` read the wall clock and `monotonicMs()` / `monotonicNanoseconds()` the duration clock. Time-dependent logic stores the `native_sdk.Clock` seam in the model (`.system` by default) so tests substitute a deterministic `native_sdk.TestClock` and advance it by hand.
|
||||
@@ -589,6 +629,7 @@ Markdown builds on the same model. `native_sdk.markdown` maps a GitHub-flavored
|
||||
```zig
|
||||
const Md = native_sdk.markdown.Markdown(Msg);
|
||||
Md.view(ui, issue.body, .{
|
||||
.images = model.markdown_images, // successful fx.loadImage mappings
|
||||
.on_link = Ui.linkMsg(.open_url),
|
||||
.on_details = Md.detailsMsg(.toggle_details),
|
||||
.details_expanded = &model.details_expanded, // caller-owned flags, elm-style
|
||||
@@ -605,10 +646,10 @@ GFM pipe tables map onto the real `table`/`table-row`/`table-cell` widgets: the
|
||||
In markup, the `<markdown>` element wires all of this declaratively:
|
||||
|
||||
```html
|
||||
<markdown source="{issue_body}" on-link="open_url" on-details="toggle_details" details-expanded="{details_expanded}" issue-link-base="ghissue://" />
|
||||
<markdown source="{issue_body}" images="{markdownImages}" on-link="open_url" on-details="toggle_details" details-expanded="{details_expanded}" issue-link-base="ghissue://" />
|
||||
```
|
||||
|
||||
`source` (required) is one `{binding}` producing the markdown text — a string field, zero-arg method, or arena-taking method. `on-link` and `on-details` are bare `Msg` tags (the runtime supplies their payloads: the URL as `[]const u8`, the details index as `usize`), `details-expanded` names a `[]const bool` iterable through the same sources `for each` accepts, and `issue-link-base` (a literal prefix or one `{binding}`) turns `#123` references into links to base ++ number. Everything but `source` is optional — without the details wiring, `<details>` blocks render collapsed and inert. Both the interpreter and the comptime compiler implement the element identically.
|
||||
`source` (required) is one `{binding}` producing the markdown text — a string field, zero-arg method, or arena-taking method. `images` optionally binds `[]const canvas.markdown.ResolvedImage`: give `canvas.markdown.collectImageSources` a caller-owned `[]canvas.markdown.CollectedImageSource`, consume each canonical source through `value()` while that storage is alive, load it through `fx.loadImage`, and retain the source, successful id, and dimensions in the model. Return those mappings from a model or arena-taking method; the view performs no I/O. `on-link` and `on-details` are bare `Msg` tags (the runtime supplies their payloads: the URL as `[]const u8`, the details index as `usize`), `details-expanded` names a `[]const bool` iterable through the same sources `for each` accepts, and `issue-link-base` (a literal prefix or one `{binding}`) turns `#123` references into links to base ++ number. Everything but `source` is optional — without the details wiring, `<details>` blocks render collapsed and inert. Both the interpreter and the comptime compiler implement the element identically.
|
||||
|
||||
## Pipeline components: stepper, timeline, and nav
|
||||
|
||||
@@ -698,10 +739,10 @@ Zig-built views get the same discipline at tree level: `canvas.expectA11yAuditSw
|
||||
|
||||
The model-contract artifact — `zig-out/model-contract.zon`, a reflection of your `Model`/`Msg` — is refreshed by `native test` in every app shape (apps that own their build can also run `zig build model-contract` directly). With that artifact fresh, `native check` verifies markup against your app's actual surface — no app compile in the loop:
|
||||
|
||||
- View → model: every binding path, `for each` iterable, `key` field, message tag, and payload type must exist on the model with the right shape, and expressions type-check with the real binding types — `{count > 'a'}` is an error naming `count` and its Zig type. Unknown names get a did-you-mean over your model's actual fields.
|
||||
- Model → view: model fields, query fns, and `Msg` tags that no view binds or dispatches are reported as **warnings** — state only `update`/fx logic touches is legitimate, so declare it: `pub const view_unbound = .{ "next_id" };` on `Model` or `Msg` opts names out. `--strict` promotes the warnings to failures.
|
||||
- View → model: every binding path, `for each` iterable, `key` field, message tag, and payload type must exist on the model with the right shape, and expressions type-check with the real binding types — `{count > 'a'}` is an error naming `count` and its core type. Unknown names get a did-you-mean over your model's actual fields.
|
||||
- Model → view: model fields, derived helpers/query functions, and `Msg` tags that no view binds or dispatches are reported as **warnings**. State only `update` or effects logic touches is legitimate, so opt it out in the core's vocabulary: TypeScript declares `export const viewUnbound = ["nextId", "tick"] as const` in `src/core.ts`; Zig declares `pub const view_unbound = .{ "next_id", "tick" };` on `Model` or `Msg`. Use the names exactly as that core authored them. `--strict` promotes the warnings to failures.
|
||||
- Template args are part of a template's interface: the kinds of use-site arguments flow into the template body (a string passed into a `width` arg fails at the use), slot content checks in the consumer's scope, and the check follows the whole `<import>` closure.
|
||||
|
||||
A missing or stale artifact (the artifact carries a hash of your Zig sources) degrades to structural checking with one loud line ("model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks") — never a false pass; the compiled engine still enforces the same contract at build time, and a conformance suite holds the two checkers to identical accept/reject sets. Model state consumed only by a Zig-built view needs `view_unbound` too — the markup checker cannot see Zig view reads.
|
||||
A missing or stale artifact (the artifact carries a hash of the app's core sources) degrades to structural checking with one loud line ("model contract: not yet built - bindings checked structurally only; run `native test` to enable typed checks") — never a false pass; the compiled engine still enforces the same contract at build time, and a conformance suite holds the two checkers to identical accept/reject sets. Model state consumed only by a Zig-built view needs `view_unbound` too — the markup checker cannot see Zig view reads.
|
||||
|
||||
For the component catalog and styling model, see [Built-in Components](/docs/built-in-components). For dropping down to the programmatic API, the `canvas.Ui(Msg)` builder produces exactly the same trees the markup compiles to.
|
||||
|
||||
@@ -86,6 +86,13 @@ The manifest drives packaging metadata:
|
||||
.platforms = .{ "macos", "linux" },
|
||||
.web_engine = "system",
|
||||
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
|
||||
.dmg = .{
|
||||
.background = "assets/dmg-background.png",
|
||||
.window_width = 660,
|
||||
.window_height = 400,
|
||||
.app_position = .{ .x = 166, .y = 182 },
|
||||
.applications_position = .{ .x = 486, .y = 182 },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -125,6 +132,10 @@ The manifest drives packaging metadata:
|
||||
<td><code>platforms</code></td>
|
||||
<td>Which platform packages to generate</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>dmg</code></td>
|
||||
<td>Optional macOS disk-image branding and Finder layout; defaults already produce a styled drag-to-Applications image</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -148,6 +159,55 @@ macOS app bundles declare `LSMinimumSystemVersion` as `11.0`. File associations
|
||||
|
||||
See [Code Signing](/docs/packaging/signing) for signing, notarization, and DMG creation.
|
||||
|
||||
### DMG archives
|
||||
|
||||
Add <code>--archive</code> to turn the packaged app into the disk image users download:
|
||||
|
||||
```bash
|
||||
native build
|
||||
native package --target macos --archive
|
||||
```
|
||||
|
||||
Without any extra configuration, Native creates a 660×400 Finder window with a quiet generated background and arrow, packages matching 1× and 2× representations for crisp Retina rendering, positions the app and an `/Applications` alias on either side, hides the Finder chrome, and compresses the result as a `.dmg`. The package diagnostic prints both the `.app` and `.dmg` paths.
|
||||
|
||||
Customize the presentation in `app.zon`:
|
||||
|
||||
```zig
|
||||
.dmg = .{
|
||||
.volume_name = "My App",
|
||||
.background = "assets/dmg-background.png",
|
||||
.window_width = 720,
|
||||
.window_height = 440,
|
||||
.icon_size = 144,
|
||||
.app_position = .{ .x = 180, .y = 210 },
|
||||
.applications_position = .{ .x = 540, .y = 210 },
|
||||
.applications_link = true,
|
||||
},
|
||||
```
|
||||
|
||||
The window dimensions are the usable background canvas, excluding Finder's title bar. Positions are icon centers measured from the canvas's top-left corner. The background must be a project-relative PNG, JPEG, or TIFF at the configured window size; Finder displays it at its natural size. `native validate` and packaging reject malformed images or dimensions that do not match the configured canvas. For Retina artwork, put a double-sized sibling next to it using the `@2x` convention—for example, `dmg-background.png` at 720×440 and `dmg-background@2x.png` at 1440×880. Native discovers the pair, verifies that the sibling is exactly double-sized, and packages both representations. A prebuilt multi-resolution TIFF also works.
|
||||
|
||||
Omitting `background` keeps Native's generated Retina-aware gradient and draws the arrow between the configured app and Applications positions. Set `applications_link = false` only for a disk image that is not meant to use the conventional drag-to-install flow.
|
||||
|
||||
For complete control over which Finder items appear and where they sit, replace the fixed app/Applications pair with `items`:
|
||||
|
||||
```zig
|
||||
.dmg = .{
|
||||
.background = "assets/dmg-background.png",
|
||||
.window_width = 760,
|
||||
.window_height = 480,
|
||||
.icon_size = 112,
|
||||
.items = .{
|
||||
.{ .kind = "app", .position = .{ .x = 150, .y = 180 } },
|
||||
.{ .kind = "applications", .position = .{ .x = 610, .y = 180 } },
|
||||
.{ .kind = "file", .path = "README.pdf", .name = "Read Me.pdf", .position = .{ .x = 250, .y = 370 } },
|
||||
.{ .kind = "link", .path = "/Library/QuickLook", .name = "QuickLook", .position = .{ .x = 510, .y = 370 } },
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
An explicit list must contain exactly one `app`; its optional `name` changes only the bundle name shown in the DMG. `applications` creates the `/Applications` alias. `file` copies a project-relative file or directory, with an optional display `name`, while `link` creates a named symbolic link to an absolute path. When `items` is present, it replaces `app_position`, `applications_position`, and `applications_link`. The generated background draws its arrow whenever the list includes both `app` and `applications`.
|
||||
|
||||
## Linux
|
||||
|
||||
### Package structure
|
||||
@@ -272,12 +332,14 @@ native validate app.zon
|
||||
In addition to `native package --target <platform>`, the CLI provides shortcut commands:
|
||||
|
||||
```bash
|
||||
native package-windows [--output path] [--binary path]
|
||||
native package-linux [--output path] [--binary path]
|
||||
native package-windows [--output path] [--binary path] [--service-binary path]
|
||||
native package-linux [--output path] [--binary path] [--service-binary path]
|
||||
native package-ios [--output path] [--binary path]
|
||||
native package-android [--output path] [--binary path]
|
||||
```
|
||||
|
||||
The desktop shortcuts use an explicit `--service-binary` when supplied; otherwise, service-bearing projects discover the normal `zig-out/bin/<app>_services[.exe]` build output just like `native package`.
|
||||
|
||||
## Platform targets
|
||||
|
||||
<table>
|
||||
|
||||
@@ -83,11 +83,11 @@ The framework repository includes a `zig build notarize` helper for local releas
|
||||
zig build notarize
|
||||
```
|
||||
|
||||
Generated apps should use `native package --target macos --signing identity ...` unless they add their own `notarize` build step. This helper does not invoke `xcrun notarytool` directly. After the signed package is created, submit it for notarization manually:
|
||||
Generated apps should use `native package --target macos --signing identity ... --archive` unless they add their own `notarize` build step. This helper does not invoke `xcrun notarytool` directly. After the signed DMG is created, submit and staple it manually (use the archive path printed by the package command):
|
||||
|
||||
```bash
|
||||
xcrun notarytool submit zig-out/package/your-app.zip --apple-id "you@example.com" --team-id "TEAMID" --password "@keychain:AC_PASSWORD" --wait
|
||||
xcrun stapler staple zig-out/package/your-app.app
|
||||
xcrun notarytool submit zig-out/package/your-app-1.0.0-macos-ReleaseFast.dmg --apple-id "you@example.com" --team-id "TEAMID" --password "@keychain:AC_PASSWORD" --wait
|
||||
xcrun stapler staple zig-out/package/your-app-1.0.0-macos-ReleaseFast.dmg
|
||||
```
|
||||
|
||||
## Chromium apps
|
||||
@@ -97,8 +97,7 @@ Chromium packages include `Chromium Embedded Framework.framework` inside the `.a
|
||||
```bash
|
||||
native cef install --version <pinned-version>
|
||||
zig build
|
||||
native package --target macos --signing identity --identity "Developer ID Application: Your Name"
|
||||
hdiutil create -volname "Your App" -srcfolder zig-out/package/your-app.app -ov -format UDZO zig-out/package/your-app.dmg
|
||||
native package --target macos --signing identity --identity "Developer ID Application: Your Name" --archive
|
||||
```
|
||||
|
||||
Use `.web_engine = "chromium"` and `.cef = .{ .dir = "third_party/cef/macos", .auto_install = false }` in `app.zon` for the normal signing path. `-Dweb-engine`, `--web-engine`, `-Dcef-dir`, and `--cef-dir` remain available for temporary overrides.
|
||||
@@ -110,9 +109,11 @@ If Gatekeeper rejects the app, check that the CEF framework is present in `Conte
|
||||
Create a distributable disk image:
|
||||
|
||||
```bash
|
||||
zig build dmg
|
||||
native package --target macos --archive
|
||||
```
|
||||
|
||||
This creates the conventional drag-to-Applications presentation by default: a generated background and arrow, positioned app and Applications icons, hidden Finder chrome, and a compressed final image. Customize it through the [`dmg` fields in `app.zon`](/docs/packaging#dmg-archives).
|
||||
|
||||
## Entitlements
|
||||
|
||||
The project includes `assets/native-sdk.entitlements` as a starting point. Customize it for your app's needs (e.g. network access, file system access, camera).
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
export const metadata = pageMetadata("persistence");
|
||||
|
||||
export default function PersistenceLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# Model Persistence
|
||||
|
||||
Model persistence stores the TypeScript core's committed `Model` without giving `update` filesystem access or making the app own a serialization loop. Declare the capability and its monotonic schema version in `app.zon`:
|
||||
|
||||
```zig:app.zon
|
||||
.capabilities = .{ "persist" },
|
||||
.persist = .{
|
||||
.version = 1,
|
||||
.debounce_ms = 500,
|
||||
.restore = .{
|
||||
.ok = "restored",
|
||||
.none = "fresh_boot",
|
||||
.err = "restore_failed",
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
The three route names refer to `Msg` arms. `ok` and `none` are void arms; `err` carries one `Uint8Array` field containing one closed reason: `corrupt`, `version_unknown`, `migrate_failed`, `io_failed`, or `rejected`.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { Cmd } from "@native-sdk/core";
|
||||
|
||||
export interface Model {
|
||||
readonly draft: Uint8Array;
|
||||
readonly saves: number;
|
||||
}
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "edited"; readonly draft: Uint8Array }
|
||||
| { readonly kind: "restored" }
|
||||
| { readonly kind: "fresh_boot" }
|
||||
| { readonly kind: "restore_failed"; readonly reason: Uint8Array };
|
||||
|
||||
export const viewUnbound = ["restored", "fresh_boot", "restore_failed"] as const;
|
||||
|
||||
export function initialModel(): Model {
|
||||
return { draft: new Uint8Array(0), saves: 0 };
|
||||
}
|
||||
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "edited":
|
||||
return [{ ...model, draft: msg.draft, saves: model.saves + 1 }, Cmd.persist()];
|
||||
case "restored":
|
||||
case "fresh_boot":
|
||||
case "restore_failed":
|
||||
return model;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Cmd.persist()` snapshots the model from that committed update. The command carries no model bytes; the compiled core exposes its generated canonical encoder to the host. The host coalesces requests on a trailing edge, performs filesystem work off the update thread, keeps at most one write in flight, and force-flushes the pending tail during backgrounding and graceful shutdown. A failed write dispatches the configured `err` route, just like a failed boot restore. The default debounce is 500 ms; `debounce_ms` accepts 0–60,000.
|
||||
|
||||
Snapshots live in the platform app-data directory as `snapshot.nsd`. Installation is atomic: the engine writes and syncs a temporary file, renames it over the primary, and keeps one structurally valid prior generation as `snapshot.nsd.bak`. A corrupt or torn primary falls back to that backup. The generated body is little-endian and uses tagged, length-delimited Model fields; snapshot bodies are bounded at 16 MiB, independently of the raw file-effect limits.
|
||||
|
||||
## Boot and replay
|
||||
|
||||
Before the first frame, the engine restores the canonical model and dispatches exactly one configured route. `restored` observes the restored model; `fresh_boot` means neither generation exists. Restore and migration failures leave the initial model in place and dispatch `restore_failed` with the reason bytes.
|
||||
|
||||
The restore result crosses the ordinary effect journal boundary. Recording stores non-empty snapshot bytes in the session blob store; replay feeds those bytes back without reading or writing the live app-data directory. `Cmd.persist()` remains on the replay command stream for fingerprint parity, but its host binding is a no-op.
|
||||
|
||||
## Schema versions and migration
|
||||
|
||||
Increase `.persist.version` whenever the `Model` shape changes, and never reuse a version. `native check` verifies that the configured `ok` and `none` routes name void `Msg` arms and `err` names a one-`Uint8Array`-field arm; `native dev --core` runs the same fence before starting its virtual host. Check also remembers the last accepted version/fingerprint pair under `.native/cache` and reports NS1068 when the shape changes without a bump or the version moves backward. The snapshot header carries that app version, the generated model-only shape fingerprint, and the compiler's snapshot-format version, so a cold checkout still fails closed at runtime: a same-version shape mismatch reports `corrupt`, and a snapshot from a future app version reports `version_unknown`. That older binary also refuses subsequent writes with `version_unknown`, preserving the newer snapshot across a rollback.
|
||||
|
||||
To accept an older version, export the pure migration hook from `src/core.ts`:
|
||||
|
||||
```typescript
|
||||
export function migrate(snapshot: Uint8Array, fromVersion: number): Model {
|
||||
// Decode the versioned legacy bytes and construct the current Model.
|
||||
// Throw a subset value when the legacy bytes cannot be migrated.
|
||||
return decodeLegacyModel(snapshot, fromVersion);
|
||||
}
|
||||
```
|
||||
|
||||
The checker requires the exact `(Uint8Array, number) => Model` shape. A successful migration is encoded in the current format, installed as the new snapshot, restored before delivery, and journaled as an ordinary successful restore. A thrown value, missing hook, or invalid result reports `migrate_failed`.
|
||||
|
||||
Version 1 persists the whole model. Do not put tokens or passwords in it; declare the `credentials` capability and permission, store them with [`Cmd.credentials.set`](/docs/capabilities), and consume get-result bytes without retaining them in Model. Credential recording is redacted and replay receives only a deterministic same-length placeholder. Raw `Cmd.readFile` and `Cmd.writeFile` remain the escape hatch for user-visible files, exports, and blobs—not the default model store.
|
||||
@@ -122,13 +122,13 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts
|
||||
</table>
|
||||
|
||||
1. iOS and Android canvas apps run full screen inside toolkit-owned hosts, which the SDK builds ON the [embed C ABI](/docs/embed) — `native dev` and `native package` with `--target ios|android` generate everything from app.zon, so the app project carries zero host code. Multi-window scenes are desktop-only. Embedding the runtime in a host app you control, driven over the same embed C ABI, works on both platforms and shares the mobile experimental status.
|
||||
2. macOS presents through Metal and hands scrolling to OS scroll drivers. Linux and Windows present the deterministic software renderer through the platform blit path and report `backend=software`; a manifest that requests another backend falls back to software there instead of erroring. The iOS toolkit host reads the software renderer's pixels over the ABI and presents them through Metal with the macOS host's presentation discipline — presents are gated on the canvas revision (an idle app acquires no drawables and uploads nothing), the drawable is acquired only after the frame's CPU work is done, and the pump pauses in the background and re-presents the retained canvas on return; the Android toolkit host copies the same pixels into its surface's window buffer (mobile GPU rendering is a later phase); embed hosts present the pixels in their own surfaces.
|
||||
3. The SDK's own TrueType pipeline — parsing, outlines, rasterization — is text rendering's shared reference path on every platform (goldens, screenshots, and software presents all ink through it), and apps can [register additional faces](/docs/fonts) (a CJK face is the canonical case) that both renderers resolve exactly like the bundled ones. macOS additionally hands registered bytes to the host at registration, so CoreText measurement and Metal packet text presentation resolve the same face the reference renderer inks. On Windows and Linux the software renderer inks registered outlines directly, and the test suite pins pixel parity between the present path and the reference path for a registered face; the full font-registry suite runs in CI on Linux, and CI additionally runs it natively on a Windows runner, including a receipt test that registers a committed subsetted CJK face and proves Chinese text renders as real glyphs, not tofu. Mobile hosts present the same reference-rendered pixels, but no mobile test registers a font today and the mobile hosts' text measurement has no registered-font seam, so registered fonts there are stated as unverified rather than supported.
|
||||
2. macOS presents through Metal and hands scrolling to OS scroll drivers. Windows presents representable retained packets through Direct2D/DirectWrite and falls back to the deterministic software renderer for unsupported commands, transparent layered windows, or unavailable GPU resources. Linux uses the software renderer through its platform blit path. Frame events report the concrete `metal`, `direct2d`, or `software` backend. The iOS toolkit host reads the software renderer's pixels over the ABI and presents them through Metal with the macOS host's presentation discipline — presents are gated on the canvas revision (an idle app acquires no drawables and uploads nothing), the drawable is acquired only after the frame's CPU work is done, and the pump pauses in the background and re-presents the retained canvas on return; the Android toolkit host copies the same pixels into its surface's window buffer (mobile GPU rendering is a later phase); embed hosts present the pixels in their own surfaces.
|
||||
3. The SDK's own TrueType pipeline — parsing, outlines, rasterization — is text rendering's shared reference path on every platform (goldens, screenshots, and software presents all ink through it), and apps can [register additional faces](/docs/fonts) (a CJK face is the canonical case) that both renderers resolve exactly like the bundled ones. macOS hands registered bytes to CoreText, and Windows hands them to DirectWrite, so packet text presentation resolves the same face the reference renderer inks. Linux and Windows software fallback ink registered outlines directly; the full font-registry suite runs in CI on Linux, and CI additionally runs it natively on a Windows runner, including a receipt test that registers a committed subsetted CJK face and proves Chinese text renders as real glyphs, not tofu. Mobile hosts present the same reference-rendered pixels, but no mobile test registers a font today and the mobile hosts' text measurement has no registered-font seam, so registered fonts there are stated as unverified rather than supported.
|
||||
4. Windows maps native IME composition onto the shared IME events, with real-hardware IME verification still pending. The iOS toolkit host forwards touch, the system keyboard, and IME composition, verified with real injected input on the simulator; the Android toolkit host forwards touch, shows and hides the soft keyboard from the runtime's focus state, and routes committed text and IME composition through the same embed IME events, verified with injected input on the emulator.
|
||||
5. App menus and context menus are native on all three desktops: macOS presents `NSMenu`, Windows `TrackPopupMenu` (the tray menu's popup path), Linux `GtkPopoverMenu`. Hosts without a native context-menu presenter — the mobile toolkit hosts and embed hosts today — present the same declared context menu as an anchored canvas surface at the click point; authors declare one menu either way.
|
||||
6. Tray support is implemented on macOS (`NSStatusItem`) and Windows; Linux tray calls return `UnsupportedService` until a portable status-notifier implementation is selected.
|
||||
7. Web engines only apply to apps that embed web content; native-rendered apps carry none. The system WebView is the default engine on every desktop platform; bundled Chromium through CEF is available on macOS only, and Linux/Windows Chromium builds fail early instead of silently substituting an engine. The Windows system engine is WebView2: its Evergreen runtime ships with Windows 11 and current Windows 10 (older machines need the runtime installer; `native doctor` checks for it), and the build stages the vendored loader next to the executable automatically. See [Web Engines](/docs/web-engines). The mobile shell examples embed the platform WebView as the content workspace.
|
||||
8. `native package` targets all five platforms: macOS gets a `.app` bundle (plus `zig build dmg`), Linux an install tree, Windows a distributable directory with a per-user file-type registration script, iOS a complete generated Xcode project — toolkit host sources, Info.plist, asset catalog, shared scheme, and the device-slice embed library — that `xcodebuild archive` builds with zero edits (code signing stays a manual step, like notarization), and Android a complete generated host project whose debug APK assembles with zero edits, directly with the SDK's build tools (store signing keys stay a manual step). Every platform's icons generate from one square source image. See [Packaging](/docs/packaging).
|
||||
8. `native package` targets all five platforms: macOS gets a `.app` bundle plus a styled drag-to-Applications DMG with `--archive`, Linux an install tree, Windows a distributable directory with a per-user file-type registration script, iOS a complete generated Xcode project — toolkit host sources, Info.plist, asset catalog, shared scheme, and the device-slice embed library — that `xcodebuild archive` builds with zero edits (code signing stays a manual step, like notarization), and Android a complete generated host project whose debug APK assembles with zero edits, directly with the SDK's build tools (store signing keys stay a manual step). Every platform's icons generate from one square source image. See [Packaging](/docs/packaging).
|
||||
9. macOS signing supports `adhoc` and `identity` modes with entitlements; notarization is submitted manually with the platform tools after packaging. No signing tooling exists yet for the other platforms. See [Code Signing](/docs/packaging/signing).
|
||||
10. The automation server is a file-based protocol the runtime serves on every desktop platform: snapshots, assertions, synthetic input, screenshots, record/replay. Engine screenshots render through the deterministic CPU reference renderer on every platform, so they are byte-comparable across hosts. Mobile exposes accessibility snapshots and actions through the embed ABI; the iOS and Android toolkit hosts serve the same file-based protocol inside the app's data container when launched with automation enabled.
|
||||
|
||||
@@ -298,6 +298,14 @@ macOS, Linux, and Windows run full desktop apps through their own platform hosts
|
||||
<td>Unsupported</td>
|
||||
<td>Supported on Windows 10 2004+ (process-scoped WASAPI loopback of this app only, probed live)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Microphone + system audio capture</td>
|
||||
<td>Supported (AVAudioEngine microphone; ScreenCaptureKit system mix on macOS 13+; user consent required)</td>
|
||||
<td>Unsupported</td>
|
||||
<td>Unsupported</td>
|
||||
<td>Unsupported</td>
|
||||
<td>Supported (default capture endpoint + WASAPI render loopback)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -328,11 +336,11 @@ JavaScript can query the same support model through the built-in bridge when `js
|
||||
const hasTray = await window.zero.platform.supports("tray");
|
||||
```
|
||||
|
||||
Feature names match the Zig `PlatformFeature` enum and the TypeScript `NativeSdkPlatformFeature` union. JavaScript callers may use either snake_case names such as `native_views` or camelCase aliases such as `nativeViews`. Unsupported operations still reject explicitly if called; support checks are intended for choosing UI affordances before making those calls.
|
||||
Feature names match the Zig `PlatformFeature` enum and the TypeScript `NativeSdkPlatformFeature` union. JavaScript callers may use either snake_case names such as `native_views`, `microphone_capture`, and `system_audio_capture` or camelCase aliases such as `nativeViews`, `microphoneCapture`, and `systemAudioCapture`. Unsupported operations still reject explicitly if called; support checks are intended for choosing UI affordances before making those calls.
|
||||
|
||||
## Native Surfaces
|
||||
|
||||
The macOS, Linux, and Windows system-WebView hosts support `toolbar`, `titlebar_accessory`, `sidebar`, `statusbar`, `split`, `stack`, `button`, `icon_button`, `list_item`, `checkbox`, `toggle`, `segmented_control`, `text_field`, `search_field`, `label`, `spacer`, and `progress_indicator` native view kinds. The macOS system-WebView host also supports `gpu_surface` as a Metal-backed child view, and the Linux and Windows system-WebView hosts support `gpu_surface` as a software (CPU reference-rendered) child view presented through the pixel blit path (cairo on Linux, GDI on Windows).
|
||||
The macOS, Linux, and Windows system-WebView hosts support `toolbar`, `titlebar_accessory`, `sidebar`, `statusbar`, `split`, `stack`, `button`, `icon_button`, `list_item`, `checkbox`, `toggle`, `segmented_control`, `text_field`, `search_field`, `label`, `spacer`, and `progress_indicator` native view kinds. Their `gpu_surface` children present through Metal on macOS, retained Direct2D/DirectWrite packets on Windows, and the CPU reference renderer plus a Cairo pixel blit on Linux. Windows retains the GDI pixel path as an exact fallback.
|
||||
|
||||
`ViewKind.webview` is the compatibility path for WebView-backed views. Existing `runtime.createWebView(...)` and `window.zero.webviews.*` APIs remain available.
|
||||
|
||||
@@ -416,7 +424,7 @@ Mobile hosts own safe areas, orientation, keyboard avoidance, back gestures, and
|
||||
|
||||
Canvas-scene apps also run through a hand-written embed host: `addMobileLib` compiles a `Model`/`Msg`/`update`/`view` app into the embed static library with a single `gpu_surface` scene, frames render through the deterministic CPU reference renderer, and the host shim blits the presented pixels (`native_sdk_app_render_pixels`) into its own surface. The `examples/mobile-canvas` iOS shim — the same architecture the toolkit's own UIKit host uses — is exercised on the simulator: rendering, safe-area layout, real touch/keyboard/IME input, and accessibility snapshots. The Android `NativeActivity` shim cross-compiles for both Android arches from the same library build.
|
||||
|
||||
`gpu_surface` is implemented for the macOS system-WebView host as a Metal-backed child surface and for the Linux and Windows system-WebView hosts as a software-rendered (CPU reference renderer + GTK pixel blit on Linux, GDI DIB blit on Windows) child surface; Linux and Windows frame events report `backend=software`, and a manifest that declares another backend (for example `gpu_backend = "metal"`) falls back to software on those hosts instead of erroring. Windows maps native IME composition (`WM_IME_COMPOSITION`) onto the same shared IME events the macOS and Linux hosts emit — inline preedit, cursor position, and the commit contract — with real-hardware IME verification still pending. Other current hosts report unsupported operations for that view kind and `runtime.supports(.gpu_surfaces)` / `window.zero.platform.supports("gpuSurfaces")` return `false`.
|
||||
`gpu_surface` is implemented for the macOS system-WebView host as a Metal-backed child surface, for the Windows system-WebView host as a retained Direct2D/DirectWrite child surface, and for the Linux system-WebView host through the deterministic CPU reference renderer plus a GTK pixel blit. Windows frame events report `backend=direct2d` while binary canvas packets remain representable; unrepresentable commands, transparent layered windows, or an unavailable Direct2D device fall back to the same CPU reference renderer plus a GDI DIB blit and report `backend=software`. Linux reports `backend=software`, and a manifest that declares another backend (for example `gpu_backend = "metal"`) falls back to software there instead of erroring. Windows maps native IME composition (`WM_IME_COMPOSITION`) onto the same shared IME events the macOS and Linux hosts emit — inline preedit, cursor position, and the commit contract — with real-hardware IME verification still pending. Other current hosts report unsupported operations for that view kind and `runtime.supports(.gpu_surfaces)` / `window.zero.platform.supports("gpuSurfaces")` return `false`.
|
||||
|
||||
## Related Docs
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Native SDK is the complete toolkit for building beautiful native desktop applica
|
||||
## Prerequisites
|
||||
|
||||
- macOS 11 or newer, Linux, or Windows
|
||||
- Node.js 22.15+ (on the 23 line: 23.5+) for the default TypeScript scaffold — the TypeScript-to-native transpiler and the core dev loop run under it at build and dev time; the binary you ship carries no JS runtime. A Zig-core app (`--template zig-core`) does not need node.
|
||||
- Node.js 22.15+ (on the 23 line: 23.5+) for the default TypeScript scaffold — the TypeScript frontend (the checker) and the core dev loop run under it at build and dev time, and the external core compiler that builds the checked core to native code ships as an exact-pinned dependency of the CLI; the binary you ship carries no JS runtime. A Zig-core app (`--template zig-core`) needs Node only when it declares relational SQLite, whose schema checker and migration generator run at build time.
|
||||
|
||||
## Get the CLI
|
||||
|
||||
@@ -65,7 +65,7 @@ This scaffolds a native-rendered app — and nothing else. There are no build fi
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Editor support is stock tsc — no extension, no plugin. `package.json` and `tsconfig.json` exist for editors and versioning only, and `node_modules/@native-sdk/core` is a CLI-managed copy of the SDK package: materialized at init, kept fresh by `native check`/`dev`/`build`, and replaced transparently by `npm install` once the package is published to npm. None of it is build truth — builds transpile against the SDK the CLI ships with and never read node_modules; delete it and every `native` verb still works.
|
||||
Editor support is stock tsc — no extension, no plugin. `package.json` and `tsconfig.json` exist for editors and versioning only, and `node_modules/@native-sdk/core` is a CLI-managed copy of the SDK package: materialized at init, kept fresh by `native check`/`dev`/`build`, and replaced transparently by `npm install` once the package is published to npm. None of it is build truth — builds check and compile against the SDK the CLI ships with and never read node_modules; delete it and every `native` verb still works.
|
||||
|
||||
There is no Zig in this tree and no language flag anywhere: the build detects `src/core.ts` and wires everything (`package.json` is not a language marker). Prefer to write the core in Zig? `native init my_app --template zig-core` scaffolds the same app with `src/main.zig` (plus generated full-loop tests in `src/tests.zig`) — the tree is the truth, and the build detects whichever core it carries. Zig is the language the whole toolkit is built in, and a Zig core is first-class by choice, not a fallback. Prefer to own `build.zig` from day one? Add `--full` to either template.
|
||||
|
||||
@@ -239,7 +239,7 @@ That is the whole loop: the model holds state, messages describe what happened,
|
||||
|
||||
## Edit while it runs
|
||||
|
||||
`src/app.native` is embedded into the binary and watched while `native dev` runs — `native dev` runs a Debug build by default, which is what arms the hot-reload watcher. Edit it — change a label, add a button — and the window updates within a couple of seconds without losing the count. Parse failures keep the last good view on screen.
|
||||
`src/app.native` is embedded into the binary and watched while `native dev` runs — `native dev` runs a Debug build by default, which is what arms the hot-reload watcher. Edit it — change a label, add a button — and the window updates within a couple of seconds without losing the count. Parse failures keep the last good view on screen. A `src/core.ts` edit is different: the core rebuilds through the external core compiler and the app restarts — a few seconds per rebuild, not sub-second — so for fast logic iteration use `native dev --core` (next section).
|
||||
|
||||
## The fastest loop: the core under node
|
||||
|
||||
@@ -280,7 +280,7 @@ info[manifest.valid]: app.zon is valid
|
||||
checked 1 markup file, app.zon and src/core.ts (subset checker clean)
|
||||
```
|
||||
|
||||
The first line is honest about what a fresh tree can check: once a build has produced the model contract, the markup pass also verifies bindings, iterables, and message tags against the core's emitted `Model`/`Msg`. Markup errors come back with `file:line:column` and a teaching message (`native markup lsp` provides the same diagnostics plus completion and hover in your editor). `native test` runs the app's test suite; the Zig template additionally scaffolds `src/tests.zig` — full-loop UI tests that click buttons through typed dispatch, headless, on any machine. See [Testing](/docs/testing) for the full tiers, including driving the live app from the outside with [automation](/docs/automation).
|
||||
The first line is honest about what a fresh tree can check: once a build has produced the model contract, the markup pass also verifies bindings, iterables, and message tags against the core's `Model`/`Msg`. Markup errors come back with `file:line:column` and a teaching message (`native markup lsp` provides the same diagnostics plus completion and hover in your editor). `native test` runs the app's test suite; the Zig template additionally scaffolds `src/tests.zig` — full-loop UI tests that click buttons through typed dispatch, headless, on any machine. See [Testing](/docs/testing) for the full tiers, including driving the live app from the outside with [automation](/docs/automation).
|
||||
|
||||
## Build a release binary
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
export const metadata = pageMetadata("record-store");
|
||||
|
||||
export default function RecordStoreLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# Record Store
|
||||
|
||||
The record store persists independent byte records without giving `update` a database handle or making the app own a file format. It is the right fit for caches, message history, and document-sized values that grow or change one record at a time. Declare the build capability in `app.zon`:
|
||||
|
||||
```zig:app.zon
|
||||
.capabilities = .{ "store" },
|
||||
```
|
||||
|
||||
`"store"` links the shared SQLite engine and opens one engine-owned `store.db` in the app-data directory. Apps name keys, never paths or SQL. The common desktop runner and the iOS and Android hosts install that data directory before the first app effect. Apps without either storage capability do not link SQLite, and `native check` warns when the `"store"` declaration and `Cmd.store.*` calls disagree. The relational tier's `"sqlite"` capability selects the same engine object, so an app declaring both still links SQLite once.
|
||||
|
||||
## A saved draft
|
||||
|
||||
Every operation is a command. The committed model changes first; the result returns later as an ordinary `Msg`.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { Cmd } from "@native-sdk/core";
|
||||
|
||||
export interface Model {
|
||||
readonly draft: Uint8Array;
|
||||
readonly loaded: boolean;
|
||||
}
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "load" }
|
||||
| { readonly kind: "loaded"; readonly result: Uint8Array }
|
||||
| { readonly kind: "edited"; readonly draft: Uint8Array }
|
||||
| { readonly kind: "saved" }
|
||||
| { readonly kind: "store_failed"; readonly reason: Uint8Array };
|
||||
|
||||
export const viewUnbound = ["loaded", "saved", "store_failed"] as const;
|
||||
|
||||
export function initialModel(): Model {
|
||||
return { draft: new Uint8Array(0), loaded: false };
|
||||
}
|
||||
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "load":
|
||||
return [model, Cmd.store.get("draft/current", {
|
||||
key: "load-draft", ok: "loaded", err: "store_failed",
|
||||
})];
|
||||
case "loaded":
|
||||
// A get result starts with 1 for a hit and 0 for a miss. The value
|
||||
// follows the hit byte, so an empty value is distinct from absence.
|
||||
return msg.result[0] === 1
|
||||
? { draft: msg.result.subarray(1), loaded: true }
|
||||
: { ...model, loaded: true };
|
||||
case "edited":
|
||||
return [{ ...model, draft: msg.draft }, Cmd.store.set(
|
||||
"draft/current",
|
||||
msg.draft,
|
||||
{ key: "save-draft", ok: "saved", err: "store_failed" },
|
||||
)];
|
||||
case "saved":
|
||||
case "store_failed":
|
||||
return model;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reissuing the same route `key` replaces the older in-flight operation, and `Cmd.cancel(key)` cancels it silently. Distinct commands issued in one commit are performed in command-stream order: a synchronous read waits for writes that precede it. A get in a later commit also observes an earlier successful set.
|
||||
|
||||
## Zig-core parity
|
||||
|
||||
Zig cores use the same runtime-owned database and result envelope. The common app runner installs the binding before the app's first effect; no Zig entry point resolves a path or opens SQLite.
|
||||
|
||||
```zig
|
||||
const Msg = union(enum) {
|
||||
store_result: native_sdk.EffectHostResult,
|
||||
};
|
||||
const Effects = native_sdk.Effects(Msg);
|
||||
|
||||
fn loadDraft(fx: *Effects) void {
|
||||
fx.storeGet(.{
|
||||
.key = 1,
|
||||
.record_key = "draft/current",
|
||||
.on_result = Effects.hostMsg(.store_result),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`Effects.storeSet`, `storeGet`, `storeDelete`, `storeScan`, and `storeSetMany` mirror the TypeScript operations. Their `EffectHostResult` carries `key`, `ok`, and `bytes`; get and scan use exactly the framing described below.
|
||||
|
||||
## Operations and bounds
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Command</th>
|
||||
<th>Behavior</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>Cmd.store.set(key, bytes, route)</code></td>
|
||||
<td>Insert or replace one value. Keys are non-empty UTF-8 up to 512 bytes; values are at most 1 MiB.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.store.get(key, route)</code></td>
|
||||
<td>Return <code>[1][value...]</code> for a hit or <code>[0]</code> for a miss through the ok arm.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.store.delete(key, route)</code></td>
|
||||
<td>Delete one value. A missing key succeeds.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.store.scan(prefix, options, route)</code></td>
|
||||
<td>Return a byte-lexicographic prefix page. <code>limit</code> defaults to 100 and is capped at 256; pass the returned next-key bytes as <code>after</code> (a known literal key may be passed as a string).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.store.setMany(entries, route)</code></td>
|
||||
<td>Insert or replace 1–64 records atomically, with an 8 MiB encoded batch bound.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
A scan page is little-endian framed bytes: `count u32`, then `count` repetitions of `key_length u32`, key bytes, `value_length u32`, value bytes, followed by `next_length u32` and the next-key bytes. An empty next key ends pagination. Pages stop at record boundaries; records are never truncated.
|
||||
|
||||
All error arms receive one closed reason as UTF-8 bytes: `io_failed`, `over_bound`, `bad_key`, `rejected`, or `busy`. Cache misses use the get ok arm because absence is an expected lookup result. `setMany` validates the entire batch before its transaction, so an invalid entry changes nothing.
|
||||
|
||||
## Replay and the virtual host
|
||||
|
||||
Store results use the ordinary effect journal. Session replay feeds the recorded result and never opens the live database. Zig full-loop tests opt into one hermetic SQLite database per harness with `TestHarness().createWithRecordStore(allocator, surface)`; it is bound before `harness.start(app)` and closed by `harness.destroy(allocator)`. `native dev --core` performs the same API against a process-local map that survives its simulated `{"restart": true}` command.
|
||||
|
||||
Use [Model Persistence](/docs/persistence) when the whole in-memory model is the unit you save. Use the record store when records grow independently. Use raw file effects only for user-visible files, exports, or blobs larger than the record bound; relational queries and secondary indexes belong in the SQL tier rather than this API.
|
||||
|
||||
The repository's [record-store example](https://github.com/vercel-labs/native/tree/main/examples/record-store) exercises all five commands from a TypeScript core and Native markup view.
|
||||
@@ -149,7 +149,7 @@ The runtime dispatches `LifecycleEvent` values through your `event_fn`:
|
||||
- **`frame`** -- a frame has been requested (for animations or state updates)
|
||||
- **`stop`** -- the app is shutting down
|
||||
|
||||
Native file drops dispatch `Event.files_dropped` to `event_fn`. Apps with WebView content also receive `app:activate`, `app:deactivate`, and `drop:files` on each trusted `window.zero` instance:
|
||||
Native file drops dispatch `Event.files_dropped` to `event_fn`; `UiApp` maps them through `Options.on_drop`, and TypeScript cores export `dropMsg(drop: FileDropEvent)` (see [Native UI: File drops](/docs/native-ui#file-drops)). Apps with WebView content also receive `app:activate`, `app:deactivate`, and `drop:files` on each trusted `window.zero` instance:
|
||||
|
||||
```ts
|
||||
window.zero.on("drop:files", (event) => {
|
||||
|
||||
@@ -61,6 +61,10 @@ Every app declares `permissions` and `capabilities` in `app.zon` — the runtime
|
||||
<td><code>microphone</code></td>
|
||||
<td>Microphone access</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>system_audio</code></td>
|
||||
<td>System-output audio capture</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>location</code></td>
|
||||
<td>Location services</td>
|
||||
@@ -323,7 +327,7 @@ External links are denied by default. To open links in the system browser, opt i
|
||||
|
||||
Do not allow broad external patterns for pages that can be influenced by remote content.
|
||||
|
||||
The same policy gates `runtime.openExternalUrl(...)` and `window.zero.os.openUrl(...)`. A bridge grant for `native-sdk.os.openUrl` is not enough by itself; the URL must also match `external_links.allowed_urls`.
|
||||
The same policy gates `Cmd.openExternalUrl(...)`, `runtime.openExternalUrl(...)`, and `window.zero.os.openUrl(...)`. A bridge grant for `native-sdk.os.openUrl` is not enough by itself; the URL must also match `external_links.allowed_urls`.
|
||||
|
||||
## CSP guidance
|
||||
|
||||
|
||||
@@ -21,10 +21,14 @@ The CLI itself serves the full skill content. This is the layer the installed di
|
||||
```sh
|
||||
native skills list # list built-in skills
|
||||
native skills get core # print a skill
|
||||
native skills get native-ui # default app views and app loop
|
||||
native skills get ts-core # default TypeScript app core
|
||||
native skills get core --full # include its reference files
|
||||
native skills get --all [--full] # print every skill
|
||||
```
|
||||
|
||||
For a normal app created by `native init`, give the agent both `native-ui` and `ts-core`: TypeScript + Native markup is the primary authoring path. Load `core --full` when the task reaches shared runtime wiring, WebViews, packaging, or native capabilities; load `zig` for an existing Zig-core app or toolkit-extension work.
|
||||
|
||||
`skills get` prints the skill to stdout, so delivering one to an agent is a single redirect into wherever your agent loads skills from:
|
||||
|
||||
```sh
|
||||
@@ -38,11 +42,11 @@ native skills get native-ui > .claude/skills/native-ui/SKILL.md
|
||||
|
||||
<dl>
|
||||
<dt><code>core</code></dt>
|
||||
<dd>The shared foundation: the mental model, project structure, <code>app.zon</code>, App and Runtime patterns, frontend integration, web engines, the JavaScript bridge, permissions, windows, WebViews, dialogs, packaging, debugging, and testing. <code>--full</code> appends its five reference files (project anatomy, App/Runtime patterns, frontend assets, web engines/packaging/debugging, bridge/security/native capabilities).</dd>
|
||||
<dd>The shared foundation and task router: it establishes TypeScript + Native markup as the default, then covers project structure, <code>app.zon</code>, lower-level App and Runtime patterns, frontend integration, web engines, the JavaScript bridge, permissions, windows, WebViews, dialogs, packaging, debugging, and testing. <code>--full</code> appends its five reference files.</dd>
|
||||
<dt><code>native-ui</code></dt>
|
||||
<dd>Authoring native-rendered apps: <code>.native</code> markup views, bindings and message dispatch, <code>Model</code>/<code>Msg</code>/<code>update</code> on the <code>UiApp</code> loop, testing markup views, hot reload, and verifying the result through the automation harness.</dd>
|
||||
<dd>The view half of primary app authoring: <code>.native</code> markup views, bindings and message dispatch, the <code>Model</code>/<code>Msg</code>/<code>update</code> loop, testing markup views, hot reload, and verification through the automation harness.</dd>
|
||||
<dt><code>ts-core</code></dt>
|
||||
<dd>Authoring <a href="/docs/typescript">TypeScript app cores</a>: the app-core subset, every checker rule by ID with its idiomatic fix, text-is-bytes, the full <code>Cmd</code>/<code>Sub</code> effect vocabulary, and the node dev loop.</dd>
|
||||
<dd>The logic half of primary app authoring: <a href="/docs/typescript">TypeScript app cores</a>, the app-core subset, every checker rule by ID with its idiomatic fix, text-is-bytes, the full <code>Cmd</code>/<code>Sub</code> effect vocabulary, and the node dev loop.</dd>
|
||||
<dt><code>automation</code></dt>
|
||||
<dd>Driving and verifying a running app through <code>native automate</code>: snapshots, readiness waits, assertions, bridge round-trips, and smoke tests.</dd>
|
||||
<dt><code>zig</code></dt>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
export const metadata = pageMetadata("sqlite");
|
||||
|
||||
export default function SqliteLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
# Relational SQLite
|
||||
|
||||
The relational tier gives a model core an engine-owned SQLite database while keeping `update` pure. Add the capability in `app.zon`:
|
||||
|
||||
```zig:app.zon
|
||||
.capabilities = .{ "sqlite" },
|
||||
```
|
||||
|
||||
The runner opens `app.db` in the app-data directory, enables WAL and foreign keys, applies pending migrations, and keeps the path private. Apps that declare neither `"sqlite"` nor `"store"` do not link SQLite. The record store uses a separate `store.db`.
|
||||
|
||||
## Make the schema append-only
|
||||
|
||||
Migration files under `src/schema/` are the source of truth. Names are contiguous and monotonic:
|
||||
|
||||
```text
|
||||
src/schema/0001_init.sql
|
||||
src/schema/0002_add_tags.sql
|
||||
```
|
||||
|
||||
Create the next file with:
|
||||
|
||||
```sh
|
||||
native db new-migration add-tags
|
||||
```
|
||||
|
||||
Use `STRICT` for ordinary generated-query tables so SQLite's storage classes agree with generated TypeScript types:
|
||||
|
||||
```sql:src/schema/0001_init.sql
|
||||
CREATE TABLE folder (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE note (
|
||||
id INTEGER PRIMARY KEY,
|
||||
folder_id INTEGER NOT NULL REFERENCES folder(id),
|
||||
title TEXT NOT NULL
|
||||
) STRICT;
|
||||
```
|
||||
|
||||
`native check` applies the complete chain to a real in-memory SQLite database. It rejects gaps, edited published migrations, invalid SQLite, non-STRICT tables, and a schema change that is not represented by a new version. The accepted hashes are written to `src/schema/migrations.lock.json`; commit that file with the migrations so append-only validation has the same authority in every checkout and in CI. At launch the runtime compares the chain with `PRAGMA user_version` and applies all pending files in one transaction. A migration failure refuses the database open; a database newer than the binary is `version_unknown` and is never repaired or downgraded automatically.
|
||||
|
||||
`native db status` compares source and installed versions. `native db reset --yes` deletes the development `app.db`, WAL, and shared-memory files; the next launch reapplies the chain. Reset never accepts an arbitrary path.
|
||||
|
||||
## Declare checked queries
|
||||
|
||||
Put named statements in `src/queries.sql`:
|
||||
|
||||
```sql:src/queries.sql
|
||||
-- name: notesInFolder :live
|
||||
SELECT n.id, n.title
|
||||
FROM note AS n
|
||||
WHERE n.folder_id = :folder
|
||||
ORDER BY n.id DESC;
|
||||
|
||||
-- name: moveNote :exec
|
||||
UPDATE note SET folder_id = :to WHERE id = :id;
|
||||
```
|
||||
|
||||
`native check` asks real SQLite to prepare every statement against the migrated schema. Missing tables or columns, invalid SQL, wrong read/write declarations, parameter mistakes, and invalid result shapes are reported at the `.sql` source with NS14xx diagnostics.
|
||||
|
||||
The accepted schema generates a flat API in `@native-sdk/core`:
|
||||
|
||||
- `Cmd.qNotesInFolder(params, route)` returns typed row pages.
|
||||
- `Cmd.qMoveNote(params)` returns a typed transaction member.
|
||||
- `Cmd.qTx([statement, ...], route)` commits all generated `:exec` members atomically.
|
||||
- `Sub.qNotesInFolder(key, params, route)` exists because the query is marked `:live`.
|
||||
- `NotesInFolderRow`, `NotesInFolderParams`, and `decodeNotesInFolderPage(bytes)` describe and decode its result.
|
||||
|
||||
The `q<Name>` spelling is intentionally flat: it stays inside the ahead-of-time core subset while retaining one-to-one names from `queries.sql`.
|
||||
|
||||
```ts:src/core.ts
|
||||
import {
|
||||
Cmd,
|
||||
Sub,
|
||||
decodeNotesInFolderPage,
|
||||
utf8Bytes,
|
||||
} from "@native-sdk/core";
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "move" }
|
||||
| { readonly kind: "rows"; readonly page: Uint8Array }
|
||||
| { readonly kind: "rows_done" }
|
||||
| { readonly kind: "wrote" }
|
||||
| { readonly kind: "db_failed"; readonly reason: Uint8Array };
|
||||
|
||||
// In update:
|
||||
return [model, Cmd.qTx([
|
||||
Cmd.qMoveNote({ id: 7, to: 2 }),
|
||||
], { key: "move-note", ok: "wrote", err: "db_failed" })];
|
||||
|
||||
// In the rows arm:
|
||||
const rows = decodeNotesInFolderPage(msg.page);
|
||||
|
||||
// In subscriptions(model):
|
||||
return Sub.qNotesInFolder("folder-notes", { folder: model.folderId }, {
|
||||
page: "rows",
|
||||
done: "rows_done",
|
||||
err: "db_failed",
|
||||
});
|
||||
```
|
||||
|
||||
SQLite `INTEGER` and `REAL` map to `number`; generated decoders reject an `INTEGER` outside JavaScript's exact ±(2^53−1) range. `TEXT` and `BLOB` map to `Uint8Array`, matching byte-honest Model storage. Generated parameters inferred from a TEXT column wrap bytes automatically. Use `dbText(bytes)` when a raw query—or a parameter whose storage class cannot be inferred, such as an FTS `MATCH` term—must bind bytes as SQLite TEXT rather than BLOB. Nullability comes from the schema.
|
||||
|
||||
## Live queries
|
||||
|
||||
A `:live` query runs when subscribed and runs again after a committed transaction touches one of its generated table dependencies. The runtime combines SQLite authorizer write targets with row-update notifications until commit, so `WITHOUT ROWID` tables and truncate-optimized deletes invalidate reliably; it coalesces invalidations once per command frame and never reruns an unrelated subscription. FTS5 shadow tables are included automatically. A `:live` declaration with no table dependency is rejected because it could never refresh.
|
||||
|
||||
Each delivery uses the same bounded page route as a one-shot query: zero or more `page` messages followed by `done`. Keep temporary pages in the Model and replace the visible result set on `done`. Changing a subscription's key, parameters, routes, SQL, or dependencies re-arms it; omitting the key cancels it. Dependencies are table-level in this release.
|
||||
|
||||
Every page and terminal crosses the session journal. Pages over 64 KiB spill into the journal's content-addressed blob store. Replay never opens SQLite: recorded one-shot and live results are fed back as ordinary Msg values, including repeated live deliveries.
|
||||
|
||||
## Raw escape hatch and bounds
|
||||
|
||||
`Cmd.db.query(sql, params, route)` and `Cmd.db.exec(statements, route)` remain available. A raw query is read-only and returns pages; one raw exec commits its entire 1–64 statement array as one transaction. `native check` warns when a raw query literal could instead be declared and checked.
|
||||
|
||||
Parameters accept `null`, finite `number`, literal `string`, `Uint8Array`, `dbText(bytes)`, and `boolean` (integer 0/1). A query accepts at most 64 parameters and 1 MiB of parameter bytes. A transaction accepts at most 8 MiB. SQL is capped at 64 KiB per statement. Results page at 256 rows or 256 KiB and never truncate a row; one result is capped at 8,192 rows or 8 MiB and rejects whole when it crosses either bound. Add `LIMIT` and keyset pagination for larger collections.
|
||||
|
||||
The binary page header is `column_count u32`, `row_count u32`, then length-prefixed UTF-8 column names. Row-major values use tags `0` NULL, `1` + signed little-endian i64, `2` + little-endian f64, `3` + length-prefixed TEXT, and `4` + length-prefixed BLOB.
|
||||
|
||||
The database boundary stays pathless. SQLite's authorizer denies `ATTACH`, `DETACH`, `VACUUM INTO`, and writes to engine-owned lifecycle PRAGMAs. Outcomes are closed: `constraint`, `busy`, `io_failed`, `corrupt`, `misuse`, `rejected`, and `cancelled`. Query keys replace; duplicate transaction keys reject loudly so a write is never silently lost.
|
||||
|
||||
Zig cores have first-class `Effects(Msg).dbQuery`, `dbExec`, `dbSubscribe`, and `dbUnsubscribe` operations over the same runtime. `native test` and `TestHarness().createWithRelationalStore` use real in-memory SQLite. See [`examples/relational-notes`](https://github.com/vercel-labs/native/tree/main/examples/relational-notes) for migrations, typed atomic writes, FTS5, page decoding, and two live queries together.
|
||||
@@ -4,6 +4,12 @@ import { CodeToggle } from "@/components/code-toggle";
|
||||
|
||||
The model stores source-of-truth state only: the raw items, the current filter, the draft text. Everything the view shows that is computable from those — counts, sums, filtered lists, formatted strings — is derived at view time, never stored. This page collects the data-flow patterns that keep a Native SDK app honest; they hold in both core languages, and the samples show each where the expression differs.
|
||||
|
||||
## Pick one canonical store
|
||||
|
||||
For small in-memory apps, the Model owns the domain data and [model persistence](/docs/persistence) snapshots it. For relational apps, [SQLite](/docs/sqlite) owns the domain data; the Model holds view state plus the row pages most recently delivered by a query. Writes go out as one atomic `Cmd.db.exec`, and reads return as later Msg values—never as a database handle or a synchronous call inside `update`. Derived UI values still come from the Model's current rows, so the rule below does not change.
|
||||
|
||||
Secrets are the exception: tokens and passwords never belong in the Model, even temporarily. Store them with [`Cmd.credentials.set`](/docs/capabilities), retrieve them as an effect result, and consume the returned Msg bytes immediately to construct the next command (for example, an authenticated fetch) while returning a Model that does not retain them. That keeps secrets out of model snapshots, state fingerprints, and ordinary view data. Session recording redacts successful credential reads and replay supplies only a same-length placeholder.
|
||||
|
||||
## Derive, don't store
|
||||
|
||||
A cached derivable must be re-maintained in every `update` arm and goes stale the moment one is missed; a derived function cannot go stale.
|
||||
|
||||
@@ -53,7 +53,7 @@ Both jobs fetch the framework into that path when it is missing, so the app's `b
|
||||
The `test` job is tier 1:
|
||||
|
||||
```yaml
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- run: zig build test -Dplatform=null
|
||||
|
||||
@@ -28,6 +28,22 @@ 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:
|
||||
|
||||
```ts
|
||||
export type ThemePack = "house" | "geist";
|
||||
|
||||
export interface Model {
|
||||
readonly theme: ThemePack;
|
||||
}
|
||||
|
||||
export function themePack(model: Model): ThemePack {
|
||||
return model.theme;
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Apps that derive their own tokens select the pack directly — `ThemeOptions.pack` is just another theme axis, exactly as switchable at runtime as the scheme:
|
||||
|
||||
```zig
|
||||
|
||||
@@ -37,6 +37,26 @@ On macOS, `title` renders the tray as a menu-bar extra: a titled `NSStatusItem`
|
||||
<td><code>[]const TrayMenuItem</code></td>
|
||||
<td><code>&.{}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>presentation</code></td>
|
||||
<td><code>TrayPresentation</code></td>
|
||||
<td><code>.{}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>activation_command</code></td>
|
||||
<td><code>[]const u8</code></td>
|
||||
<td><code>""</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>alternate_activation_command</code></td>
|
||||
<td><code>[]const u8</code></td>
|
||||
<td><code>""</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>open_command</code></td>
|
||||
<td><code>[]const u8</code></td>
|
||||
<td><code>""</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -76,6 +96,26 @@ On macOS, `title` renders the tray as a menu-bar extra: a titled `NSStatusItem`
|
||||
<td><code>bool</code></td>
|
||||
<td><code>true</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>detail</code></td>
|
||||
<td><code>[]const u8</code></td>
|
||||
<td><code>""</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>role</code></td>
|
||||
<td><code>TrayItemRole</code></td>
|
||||
<td><code>.command</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>key</code></td>
|
||||
<td><code>[]const u8</code></td>
|
||||
<td><code>""</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>modifiers</code></td>
|
||||
<td><code>ShortcutModifiers</code></td>
|
||||
<td><code>.{}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -83,15 +123,61 @@ On macOS, `title` renders the tray as a menu-bar extra: a titled `NSStatusItem`
|
||||
|
||||
Use the runtime methods from app code:
|
||||
|
||||
- `runtime.createStatusItem(id, options)` -- create a status item under a stable non-zero id
|
||||
- `runtime.updateStatusItemShell(id, shell)` -- update icon, tooltip, visibility, and activation/open commands in place
|
||||
- `runtime.updateStatusItemMenu(id, items)` -- update one menu without recreating its status item
|
||||
- `runtime.updateStatusItemPresentation(id, presentation)` -- update one live title and visual presentation
|
||||
- `runtime.removeStatusItem(id)` -- remove only the identified item
|
||||
- `runtime.createTray(options)` -- create or replace the tray icon
|
||||
- `runtime.updateTrayMenu(items)` -- update menu items without recreating the tray
|
||||
- `runtime.updateTrayTitle(title)` -- update only the live tray title
|
||||
- `runtime.updateTrayPresentation(presentation)` -- update the live title and visual presentation
|
||||
- `runtime.removeTray()` -- remove the tray icon
|
||||
|
||||
The lower-level `PlatformServices.createTray`, `updateTrayMenu`, and `removeTray` hooks are available for platform adapters. `Runtime` validates tray options before dispatch: non-separator menu items need a label, command-backed items need a unique non-zero `id`, and menus are capped at 32 items.
|
||||
The singular `*Tray` methods are compatibility wrappers for reserved status-item id `1`. The lower-level `PlatformServices` surface exposes the same keyed methods for platform adapters. `Runtime` supports at most eight simultaneous status items and validates every menu independently: non-separator rows need a label, command-backed rows need a unique non-zero row `id`, and each menu is capped at 32 rows.
|
||||
|
||||
## TypeScript: model-derived status items
|
||||
|
||||
In a TypeScript app, export `statusItem(model)` from `src/core.ts`. The generated launcher installs it from the committed boot model and re-runs it after every model update. Shell, presentation, and menu are hashed independently, so changing the icon, tooltip, click hooks, title, width, tone, icon opacity, number style, or rows patches only that channel and never recreates the native status item.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { asciiBytes, utf8Bytes } from "@native-sdk/core";
|
||||
import { type StatusItemState } from "@native-sdk/core/events";
|
||||
|
||||
export function statusItem(model: Model): StatusItemState {
|
||||
return {
|
||||
iconPath: asciiBytes("assets/menu-bar.svg"),
|
||||
tooltip: utf8Bytes("Player status"),
|
||||
activationCommand: asciiBytes("app.refresh"),
|
||||
alternateActivationCommand: asciiBytes("player.toggle"),
|
||||
openCommand: asciiBytes("app.refresh"),
|
||||
presentation: {
|
||||
title: model.playing ? utf8Bytes("MB PLAY") : utf8Bytes("MB"),
|
||||
width: model.playing ? 72 : 48,
|
||||
tone: model.failed ? "critical" : "normal",
|
||||
iconOpacity: model.stale ? 0.5 : 1,
|
||||
monospaced: true,
|
||||
},
|
||||
items: [
|
||||
{ id: 10, label: model.today, command: asciiBytes(""), separator: false, enabled: false, detail: model.quota, role: "hero", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
{ id: 0, label: asciiBytes(""), command: asciiBytes(""), separator: true, enabled: false, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
{ id: 3, label: utf8Bytes("Settings…"), command: asciiBytes("app.settings"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(","), modifiers: { primary: false, command: true, control: false, option: false, shift: false } },
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`iconPath`, `tooltip`, `activationCommand`, `alternateActivationCommand`, and `openCommand` update live alongside presentation and rows. A normal click emits `activationCommand` and opens the menu; an Option-click emits `alternateActivationCommand` without opening it. Every menu open emits `openCommand`, which is useful for an on-demand refresh while the background cadence stays slow. These lifecycle hooks dispatch with `source = .tray`; empty commands disable them.
|
||||
|
||||
For multiple independent items, export `statusItems(model): readonly StatusItemDescriptor[]` instead. Each descriptor has the same shell, presentation, and row fields plus a stable non-zero `id` and live `visible` flag. Presence creates, absence removes, and changed fields patch only that identifier; menus update without replacing their `NSStatusItem`. Export either `statusItem` or `statusItems`, not both. This is the Vercel-shaped split: one spend indicator can appear or disappear while a separate control-menu item persists.
|
||||
|
||||
Rows use the exact `StatusItemMenuItem` record. `role` is `command`, `info`, `header`, `hero`, `agent`, or `context`; capable macOS hosts render the readout roles as native rich content while simpler hosts degrade them to text. `detail` carries secondary readout content, and `key` plus the five explicit `modifiers` fields declares a menu equivalent. Actionable rows need unique non-zero ids; separators conventionally use id 0 and empty byte fields. The menu may contain at most 32 rows. Map every row/click/open command to an ordinary message with `commandMsg(name): Msg | null`; no Zig `status_item_fn` glue is needed.
|
||||
|
||||
Use `utf8Bytes` for titles, labels, tooltips, and details; it preserves characters such as `…`, `·`, and emoji as UTF-8. Use `asciiBytes` for guaranteed-ASCII command names, keys, paths, and empty byte fields. Passing non-ASCII literal/template text to `asciiBytes` is an NS1064 build error.
|
||||
|
||||
## Handling tray actions
|
||||
|
||||
When a user clicks a tray menu item, the runtime dispatches a `CommandEvent` with source `.tray` and the native `tray_item_id`. Prefer command-backed items when the item represents a known app action:
|
||||
When a user clicks a tray menu item, the runtime dispatches a `CommandEvent` with source `.tray`, the native `status_item_id`, and that menu's `tray_item_id`. Row ids only need to be unique within their own menu. Prefer command-backed items when the item represents a known app action:
|
||||
|
||||
```zig
|
||||
try runtime.createTray(.{
|
||||
@@ -146,9 +232,9 @@ app.* = PreviewApp.init(allocator, .{}, .{
|
||||
|
||||
macOS (`NSStatusItem`) is the proven host; platforms without a status-bar service log a warning and continue. See `examples/canvas-preview` for the live composition.
|
||||
|
||||
## Model-driven title and menu
|
||||
## Model-driven title and menu in Zig-core apps
|
||||
|
||||
For a live menu-bar extra — an open-count badge in the title, the latest items in the dropdown — add `UiApp.Options.status_item_fn`. It is consulted on install and after every rebuild, and the runtime re-applies only what actually changed: a title-only change retitles the live status button (no flicker, no menu rebuild), a menu change updates the dropdown. The static `status_item` still provides the icon and tooltip.
|
||||
For a live menu-bar extra — an open-count badge in the title, the latest items in the dropdown — add `UiApp.Options.status_item_fn`. It is consulted on install and after every rebuild, and the runtime re-applies only what actually changed: shell, presentation, and menu changes patch independently without flicker or native-item recreation. The static `status_item` provides defaults for icon, tooltip, activation, alternate-activation, and open commands; the callback may update those fields live too.
|
||||
|
||||
```zig
|
||||
fn statusItem(model: *const Model, scratch: *App.StatusItemScratch) App.StatusItemState {
|
||||
@@ -156,13 +242,18 @@ fn statusItem(model: *const Model, scratch: *App.StatusItemScratch) App.StatusIt
|
||||
scratch.items[0] = .{ .id = 1, .label = "Refresh", .command = "app.refresh" };
|
||||
scratch.items[1] = .{ .separator = true };
|
||||
scratch.items[2] = .{ .id = 10, .label = model.latest_title, .command = "issue.select.latest" };
|
||||
return .{ .title = title, .items = scratch.items[0..3] };
|
||||
return .{
|
||||
.presentation = .{ .title = title, .width = 62, .monospaced = true },
|
||||
.items = scratch.items[0..3],
|
||||
};
|
||||
}
|
||||
// options: .status_item_fn = statusItem,
|
||||
```
|
||||
|
||||
Selections dispatch each item's `command` through `on_command` with source `.tray`, the same shape as window menus. Platforms without a tray-title seam keep the menu updates and log the missing title support once.
|
||||
|
||||
For multiple Zig-core items, use `UiApp.Options.status_items_fn`, returning up to eight `App.StatusItemDescriptor` values from `App.StatusItemsScratch`. Descriptor presence creates/removes by `id`; `visible` hides without removing; shell, presentation, and menu hashes reconcile independently. It is mutually exclusive with the singular `status_item` / `status_item_fn` pair.
|
||||
|
||||
## The menu-bar app lifecycle
|
||||
|
||||
The tray-player pattern (a Spotify-shaped app that lives in the menu bar) is two declarations and two verbs:
|
||||
@@ -192,7 +283,7 @@ pub fn update(model: *Model, msg: Msg, fx: *Effects) void {
|
||||
}
|
||||
```
|
||||
|
||||
In the TypeScript tier the same verbs are `Cmd.showWindow("main")` and `Cmd.quitApp()`. `examples/menu-bar` is the whole loop, tested end to end.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle";
|
||||
|
||||
An app core is pure TypeScript: no npm packages run inside it, because no JS engine ships in the binary. That line is drawn on purpose, and it buys the properties the rest of the toolkit stands on — byte-identical record→replay, headless testing of the whole app, automation over real state, and native dispatch speed with zero allocation at runtime. The language inside the core is complete ([TypeScript Cores](/docs/typescript) covers exactly what that means); the ecosystem lives at the edges, and every edge has a first-class pattern.
|
||||
|
||||
The question behind "can I use npm?" is almost always one of these four:
|
||||
The question behind "can I use npm?" is almost always one of these five:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
@@ -15,6 +15,11 @@ The question behind "can I use npm?" is almost always one of these four:
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Filesystem, JSON/regex parsing, transforms, or imperative work in ordinary TypeScript</td>
|
||||
<td>A compiled module under <code>src/services/</code></td>
|
||||
<td>A native service-host process, reached through <code>@native-sdk/services</code> commands</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>An HTTP API — including AI/LLM endpoints</td>
|
||||
<td><code>Cmd.fetch</code> with routed results</td>
|
||||
@@ -38,9 +43,15 @@ The question behind "can I use npm?" is almost always one of these four:
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Compiled TypeScript services
|
||||
|
||||
Put ordinary static-tier TypeScript under `src/services/` when the work needs Node built-ins, regexes, JSON, `Map`/`Set`, `Date`, classes, or ambient process authority. Each directly exported, non-default named synchronous function becomes an operation named `<module-basename>.<export>`. Its request and result may be shared, contract-encodable records; the core calls the generated constructor from `@native-sdk/services`, so success and failure still arrive as Msgs and record/replay remains offline.
|
||||
|
||||
This is compiled native code with no JavaScript engine. `native vendor . package@X.Y.Z` resolves an exact package graph once, with lifecycle scripts disabled, into checked-in `src/services/vendor/` sources and hash facts in app.zon. Builds are offline: every byte is verified and scriptc receives only the explicit `--npm-static` package list—never automatic or dynamic fallback. `native check` preserves scriptc's coverage note and refuses anything below 100% static coverage. 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.
|
||||
|
||||
## Calling APIs, AI endpoints included
|
||||
|
||||
Most packages people reach for first — API clients, AI SDKs — are HTTP wrappers. The HTTP is already in the toolkit: `Cmd.fetch` performs a buffered exchange on the effect engine and routes the result back as an ordinary Msg carrying `{ status, body }`. The request is data, the response is a message, and a recorded session replays the whole conversation with zero network — which is not something an SDK dependency can offer. A complete client for an OpenAI-compatible chat endpoint:
|
||||
Most packages people reach for first — API clients, AI SDKs — are HTTP wrappers. The HTTP is already in the toolkit: `Cmd.fetch` can perform a buffered exchange and route `{ status, body }`, or line-stream an SSE/NDJSON response through repeated Msgs. The request is data, every response event is a message, and a recorded session replays the whole conversation with zero network — which is not something an SDK dependency can offer. A complete buffered client for an OpenAI-compatible chat endpoint:
|
||||
|
||||
<CodeToggle>
|
||||
|
||||
@@ -83,7 +94,7 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
case "answered":
|
||||
// The status is the real HTTP status - a 404 is a delivered
|
||||
// response. Parse the body in pure TypeScript over bytes; the
|
||||
// ai-chat-ts example ships the complete JSON walk.
|
||||
// The Chatbot example ships the complete JSON walk.
|
||||
return { ...model, waiting: false, answer: msg.body };
|
||||
case "ask_failed":
|
||||
// The transport reason ("timed_out", "connect_failed", ...) -
|
||||
@@ -108,7 +119,25 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
|
||||
</CodeToggle>
|
||||
|
||||
The flagship version of this pattern is [`examples/ai-chat-ts`](https://github.com/vercel-labs/native/tree/main/examples/ai-chat-ts): a chat client for an OpenAI-compatible endpoint — conversation history in the Model, request encoding and response parsing as plain subset TypeScript over bytes, endpoint and credentials through the env channel with the key riding a runtime-built `Authorization: Bearer` header (header names are compile-time; header values may be runtime bytes), honest sending/failed/unconfigured states — with an end-to-end suite that pins the exact request bytes and replays a recorded conversation with no network in the room and none of the launch variables set. One v1 boundary, stated plainly: responses are buffered, not token-streamed (the engine underneath already streams line-framed bodies on the Zig channel; the TS Cmd surface for it is roadmap).
|
||||
For token-by-token UI, request the endpoint's streaming mode and add a `line` route:
|
||||
|
||||
```ts
|
||||
Cmd.fetch(
|
||||
{
|
||||
url: endpoint,
|
||||
method: "POST",
|
||||
headers: { accept: "text/event-stream", authorization: bearerToken },
|
||||
body: requestBody,
|
||||
timeoutMs: 120000,
|
||||
maxLineBytes: 65536,
|
||||
},
|
||||
{ key: "chat", line: "chat_event", ok: "chat_done", err: "chat_failed" },
|
||||
)
|
||||
```
|
||||
|
||||
`chat_event` carries one `Uint8Array` field for each complete SSE/NDJSON line; parse its `data:` payload and append the delta to the assistant message in the Model. `chat_done` carries one number field with the terminal HTTP status. Cancellation and transport failures reach `chat_failed` as reason bytes, including `cancelled`, so a partially displayed answer never ends silently.
|
||||
|
||||
The flagship [`examples/chatbot`](https://github.com/vercel-labs/native/tree/main/examples/chatbot) uses that streaming shape against Vercel AI Gateway: the Gateway URL and `openai/gpt-5.6-luna` default are fixed, a dropdown inside the prompt group lists the Luna, Terra, and Sol variants in that order, `AI_GATEWAY_API_KEY` and an optional initial `NATIVE_SDK_CHAT_MODEL` override arrive through the env channel, and every `choices[0].delta.content` extends the visible pending assistant reply before `[DONE]` and the terminal status commit it to history. Its end-to-end suite pins the request, observes partial UI updates, and replays every stream line without network or launch variables.
|
||||
|
||||
## Full npm ecosystem UIs
|
||||
|
||||
@@ -149,7 +178,8 @@ import { parseCsvRow } from "./csv.ts"; // vendored under src
|
||||
import { containsIgnoreCase } from "@native-sdk/core/text"; // the SDK library channel
|
||||
```
|
||||
|
||||
- **Vendor it under `src/`.** Subset-clean TypeScript compiles into the core like your own modules ([splitting a core into modules](/docs/typescript#splitting-a-core-into-modules)); the subset checker tells you immediately — by rule ID, with the rewrite — whether a vendored file fits. Code that leans on classes, exceptions, or regexes generally wants rewriting rather than vendoring, and the rewrite is usually smaller than the dependency.
|
||||
- **`@native-sdk/core/*` is the curated library channel**: SDK modules written in the same subset, transpiled into your core when imported and absent when not. Today that is `@native-sdk/core/text` — the byte-splice text engine (caret, selection, IME, case-insensitive search) — and `@native-sdk/core/events` — the canonical event record types markup and the wiring channels match. The channel grows with the toolkit; JSON encoding/parsing over bytes, today demonstrated in `examples/ai-chat-ts/src/api.ts`, is the kind of module it exists to absorb.
|
||||
- **Vendor subset-clean code under `src/` outside `src/services/`.** It compiles into the core like your own modules ([splitting a core into modules](/docs/typescript#splitting-a-core-into-modules)); the subset checker tells you immediately — by rule ID, with the rewrite — whether it fits.
|
||||
- **Vendor ordinary static-tier code or exact npm packages under `src/services/`.** Classes, regexes, JSON, `Map`/`Set`, `Date`, Node built-ins, and imperative transforms stay in TypeScript and compile into the service host; run `native vendor . package@X.Y.Z` for a package, then reach its typed exported operations through `@native-sdk/services`.
|
||||
- **`@native-sdk/core/*` is the curated library channel**: SDK modules written in the same subset, compiled into your core when imported and absent when not. Today that is `@native-sdk/core/text` — the byte-splice text engine (caret, selection, IME, case-insensitive search) — and `@native-sdk/core/events` — the canonical event record types markup and the wiring channels match. The channel grows with the toolkit; JSON encoding/parsing over bytes, today demonstrated in `examples/chatbot/src/api.ts`, is the kind of module it exists to absorb.
|
||||
|
||||
One thing deliberately does not exist: a package manager for cores. A core's import graph is exactly the files under `src/` plus the SDK modules — the whole program is readable, the build is hermetic, and nothing arrives at build time that you have not checked in.
|
||||
One thing deliberately does not exist: a package manager for cores. A core's import graph is exactly its class under `src/` plus the SDK modules. Services stay hermetic: local and npm sources are checked in under `src/services/`, app.zon pins each npm name/version/tree hash, and build performs no install or network step. Nothing arrives at build time that you have not checked in.
|
||||
|
||||
@@ -2,9 +2,9 @@ import { CodeToggle } from "@/components/code-toggle";
|
||||
|
||||
# TypeScript Cores
|
||||
|
||||
An app core is the logic tier of a Native SDK app: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. By default you write it as one TypeScript module — `src/core.ts` — and the `@native-sdk/core` transpiler compiles it to native code at build time. No JS engine ships in the binary: the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason.
|
||||
An app core is a Native SDK app's deterministic logic: `Model` (the app state), `Msg` (a discriminated union of everything that can happen), `update(model, msg)` (the one pure transition function), and the pure helpers they call. By default you write it as one TypeScript module — `src/core.ts` — the `@native-sdk/core` frontend checks it, and the external core compiler builds it to native code at build time. No JS engine ships in the binary: the program either passes the subset checker and compiles to native, or you get a teaching error naming the rule, the fix, and the reason.
|
||||
|
||||
This is the two-tier shape of the toolkit: Zig is how everything works — the engine, the runtime, every widget — and TypeScript plus [Native markup](/docs/native-ui) are how applications are authored. A whole app is three files and zero Zig: `src/core.ts`, `src/app.native`, and `app.zon`. Writing the core in Zig instead ([App Model](/docs/app-model)) is first-class by choice — same loop, same runtime — and extending the toolkit itself (custom widgets, host services, render passes) is always Zig.
|
||||
This is the two-tier shape of the toolkit: Zig is how everything works — the engine, the runtime, every widget — and TypeScript plus [Native markup](/docs/native-ui) are how applications are authored. A whole app starts as three files and zero Zig: `src/core.ts`, `src/app.native`, and `app.zon`. When ordinary TypeScript work needs filesystem access, JSON, regexes, `Map`, `Date`, classes, or child processes, add modules under `src/services/`; they compile to native code too and answer the core through the same effect→Msg boundary as every other external action. Writing the core in Zig instead ([App Model](/docs/app-model)) is first-class by choice — same loop, same runtime — and extending the toolkit itself (custom widgets and render passes) is always Zig.
|
||||
|
||||
The same `core.ts` is executable TypeScript: it typechecks with stock tsc and runs unmodified under node, which is what makes the fastest dev loop possible:
|
||||
|
||||
@@ -51,7 +51,7 @@ export function update(model: Model, msg: Msg): Model {
|
||||
A complete core in the idiom — readonly interfaces, a tagged Msg, spread updates, map/filter, bytes for text, derived exports:
|
||||
|
||||
```ts:src/core.ts
|
||||
import { asciiBytes } from "@native-sdk/core";
|
||||
import { utf8Bytes } from "@native-sdk/core";
|
||||
|
||||
export type Bytes = Uint8Array;
|
||||
export type Filter = "all" | "active" | "done";
|
||||
@@ -77,7 +77,7 @@ export type Msg =
|
||||
|
||||
export function initialModel(): Model {
|
||||
return {
|
||||
tasks: [{ id: 1, title: asciiBytes("Ship the core"), done: false }],
|
||||
tasks: [{ id: 1, title: utf8Bytes("Ship the core"), done: false }],
|
||||
nextId: 2,
|
||||
filter: "all",
|
||||
draft: new Uint8Array(0),
|
||||
@@ -119,17 +119,22 @@ export function update(model: Model, msg: Msg): Model {
|
||||
}
|
||||
```
|
||||
|
||||
Markup binds your model's field names exactly as you wrote them: `nextId` binds as `{nextId}` (the emitted Zig keeps the TS spellings), string-literal unions bind as their member name (`{filter}` renders `all`), and record arrays iterate with `<for each="tasks" as="t" key="id">`. Exported helpers taking exactly one `Model` parameter join the binding surface as derived values — `{doneCount}` reads `doneCount`, and slice-returning ones like `visibleTasks` drive `for each` — so derived data needs no model field. Update-only state nothing in markup binds (host-fired timer arms, bookkeeping fields) is declared once as `export const viewUnbound = ["tick"] as const;` so `native check`'s unbound-state lint stays honest.
|
||||
Markup binds your model's field names exactly as you wrote them: `nextId` binds as `{nextId}` (the core's model keeps the TS spellings), string-literal unions bind as their member name (`{filter}` renders `all`), and record arrays iterate with `<for each="tasks" as="t" key="id">`. Exported helpers taking exactly one `Model` parameter join the binding surface as derived values — `{doneCount}` reads `doneCount`, and slice-returning ones like `visibleTasks` drive `for each` — so derived data needs no model field. Update-only state nothing in markup binds (host-fired timer arms, bookkeeping fields) is declared once as `export const viewUnbound = ["tick"] as const;` so `native check`'s unbound-state lint stays honest.
|
||||
|
||||
## Why the immutable style is free
|
||||
|
||||
Everything `update` builds lives in a per-dispatch arena that is freed wholesale after the returned model is committed. At commit, only nodes your update actually created are copied into the persistent model heap — everything you spread through unchanged is shared with the previous model. `{ ...model, tasks: model.tasks.map(...) }` copies one small struct and one pointer array, never the world.
|
||||
|
||||
Both regions have fixed, build-time capacities (1 MiB each by default): the frame arena bounds one dispatch's transients, the model heap bounds the committed model. They are knobs of the emitted core — `--frame-cap <bytes>` / `--heap-cap <bytes>` on the transpiler CLI — and never grow at runtime, so binaries stay allocation-free and replay stays trivially deterministic. Overflowing one is a loud runtime panic naming the knob to raise, never silent corruption.
|
||||
Both regions have fixed, build-time capacities (1 MiB each by default): the frame arena bounds one dispatch's transients, the model heap bounds the committed model. Neither grows at runtime, so binaries stay allocation-free and replay stays trivially deterministic. Overflowing one is a defined runtime panic naming the region, never silent corruption.
|
||||
|
||||
## The subset posture
|
||||
|
||||
App cores are written in a closed subset of TypeScript, and the subset means one precise thing: TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Every basic statement, operator, and declaration form compiles: plain interfaces, discriminated unions, `switch` (with `default` arms), every loop shape (`for`, `for...of`, `while`, `do...while`, labels with labeled `break`/`continue`), the full operator and assignment family (`**`, shifts, `+=` through `??=`), const record destructuring, namespace imports, spreads, the array methods (`.map`/`.filter`/`.find`/`.reduce`/`.toSorted`/...), `Math`, template literals — everything with exact JS semantics, pinned so node and native always agree (a machine-checked grammar matrix classifies every production of the language, so nothing is missing by accident). Classes and exceptions compile too: data classes (fields, a constructor, methods, `static` methods and `static readonly` consts, erased `private`/`protected` — `new Task(...)`, `this.count`, `Task.fromRow(...)`, mutation under the same local-ownership rule as arrays) emit as plain structs plus functions, and `throw`/`try`/`catch`/`finally` is deterministic control flow — a thrown kind-tagged subset value unwinds to the nearest catch (several distinct shapes may throw; the checker collects them into the core's thrown union, and `catch (e)` narrows it with plain kind tests, no `as` ceremony), `finally` runs on every path, and an uncaught throw is a defined panic exactly where node would crash. What isn't available is exactly two families: the ecosystem the binary cannot carry (npm packages, regexes, `JSON`, Promises, `eval` — no JS engine ships) and constructs that would break the core's guarantees (class inheritance, `async`/`await` — asynchrony is command data, `Map`/`Set`, module-level `let`, `Date.now()`/`Math.random()` inside `update`, runtime type tests, text as indexable strings — a core's text is bytes). Each has an idiomatic replacement the checker teaches by ID (NS1001–NS1059) — kind-tagged error shapes narrowed in the catch, time and randomness arrive as message payloads, keyed data is an id-keyed array. Immutability is a rule about SHARED data, not a style: mutation is legal on locally-owned arrays — a scratch array your function creates (a literal or a `.slice()` copy) takes `push`/`pop`/`splice`/in-place `sort`, the `xs[xs.length] = v` append, and the rest with exact JS semantics until the value escapes; a `let` reassigned only from fresh copies stays owned, passing into a `readonly T[]` reader parameter borrows instead of escaping, and the checker teaches only at the real boundaries. Generics are ordinary TypeScript too: a module-level generic function, interface, or type alias monomorphizes per call site from tsc's own resolved type arguments — one readable native function per instantiation. These rules scope to app cores, the logic tier; they say nothing about the TypeScript you write anywhere else. Where the npm ecosystem fits — calling APIs (AI endpoints included), embedding npm-heavy web UIs, running node as a worker, vendoring utilities — has its own page: [Where Packages Go](/docs/typescript/packages).
|
||||
App cores are written in a closed subset of TypeScript, and the subset means one precise thing: TypeScript minus the ecosystem minus the purity violations — never minus basic syntax. Every basic statement, operator, and declaration form compiles: plain interfaces, discriminated unions, `switch` (with `default` arms), every loop shape (`for`, `for...of`, `while`, `do...while`, labels with labeled `break`/`continue`), the full operator and assignment family (`**`, shifts, `+=` through `??=`), const record destructuring, namespace imports, spreads, the array methods (`.map`/`.filter`/`.find`/`.reduce`/`.toSorted`/...), `Math`, template literals — everything with exact JS semantics, pinned so node and native always agree (a machine-checked grammar matrix classifies every production of the language, so nothing is missing by accident). Classes and exceptions compile too: data classes (fields, a constructor, methods, `static` methods and `static readonly` consts, erased `private`/`protected` — `new Task(...)`, `this.count`, `Task.fromRow(...)`, mutation under the same local-ownership rule as arrays) compile to plain structs plus functions, and `throw`/`try`/`catch`/`finally` is deterministic control flow — a thrown kind-tagged subset value unwinds to the nearest catch (several distinct shapes may throw; the checker collects them into the core's thrown union, and `catch (e)` narrows it with plain kind tests, no `as` ceremony), `finally` runs on every path, and an uncaught throw is a defined panic exactly where node would crash. What isn't available is exactly two families: the ecosystem the core cannot carry (npm packages, regexes, `JSON`, Promises, `eval`) and constructs that would break the core's guarantees (class inheritance, `async`/`await` — asynchrony is command data, `Map`/`Set`, module-level `let`, `Date.now()`/`Math.random()` inside `update`, runtime type tests, text as indexable strings — a core's text is bytes). Each has an idiomatic replacement the checker teaches by ID — kind-tagged error shapes narrowed in the catch, time and randomness arrive as message payloads, keyed data is an id-keyed array, and ordinary static-tier work moves behind a `src/services/` request. Immutability is a rule about SHARED data, not a style: mutation is legal on locally-owned arrays — a scratch array your function creates (a literal or a `.slice()` copy) takes `push`/`pop`/`splice`/in-place `sort`, the `xs[xs.length] = v` append, and the rest with exact JS semantics until the value escapes; a `let` reassigned only from fresh copies stays owned, passing into a `readonly T[]` reader parameter borrows instead of escaping, and the checker teaches only at the real boundaries. Generics are ordinary TypeScript too: a module-level generic function, interface, or type alias monomorphizes per call site from tsc's own resolved type arguments — one native function per instantiation. These rules scope to the core class. Files under `src/services/` skip NS1001–NS1064 and are judged by the same pinned compiler's ordinary static tier instead; the class boundary rules NS1065–NS1067 keep the deterministic core and ambient-authority service separate. Where the ecosystem fits has its own page: [Where Packages Go](/docs/typescript/packages).
|
||||
|
||||
Every rule in the catalogue carries a class. A `guarantee` rule protects a core invariant — determinism and replay, fixed shapes, immutability of shared data, the one text representation — and is permanent. A `deferred` rule bans nothing those invariants require; the capability waits on a deliberate easing decision, and its diagnostic says so.
|
||||
|
||||
- `guarantee` — permanent: NS1001 (shared data is immutable), NS1002 (updates are synchronous), NS1005 (update is deterministic), NS1010 (module state lives in the Model), the byte-text rules (NS1004, NS1018, NS1024, NS1060), and every other rule not listed as deferred.
|
||||
- `deferred` — awaiting an easing decision: NS1011 (`Map`/`Set`), NS1019 (fixed arity: parameter defaults, rest, `arguments`, call spreads), NS1040 (regular expressions), NS1042 (generators), NS1044 (`BigInt`/`Symbol`).
|
||||
|
||||
<CodeToggle>
|
||||
|
||||
@@ -144,7 +149,7 @@ export function lastNum(ns: readonly number[]): number { return pick(ns, ns.leng
|
||||
```
|
||||
|
||||
```zig
|
||||
// The emitted core: one monomorphic fn per distinct instantiation, deduped.
|
||||
// The Zig-core equivalent: one monomorphic fn per distinct instantiation.
|
||||
pub fn pick__Task(xs: []const Task, i: i64) Task {
|
||||
return xs[uz(i)];
|
||||
}
|
||||
@@ -155,18 +160,19 @@ pub fn pick__f64(xs: []const f64, i: i64) f64 {
|
||||
|
||||
</CodeToggle>
|
||||
|
||||
One rule deserves calling out early: **text is bytes**. Dynamic, user-visible text lives in the Model as `Uint8Array` — `string` is for literals, string-literal-union tags, and `===` comparisons. Turn literals and templates into bytes with the `asciiBytes` intrinsic:
|
||||
One rule deserves calling out early: **text is bytes**. Dynamic, user-visible text lives in the Model as `Uint8Array` — `string` is for literals, string-literal-union tags, and `===` comparisons. Turn display literals and templates into UTF-8 with `utf8Bytes`; use `asciiBytes` only when ASCII is part of the value's contract, such as a command name, key, or protocol token:
|
||||
|
||||
```ts
|
||||
import { asciiBytes } from "@native-sdk/core";
|
||||
import { asciiBytes, utf8Bytes } from "@native-sdk/core";
|
||||
|
||||
const label = asciiBytes(`${done} of ${total} done`); // per-dispatch bytes
|
||||
const seed = asciiBytes("Stretch"); // rodata, free to commit
|
||||
const label = utf8Bytes(`${done} of ${total} done`); // per-dispatch UTF-8
|
||||
const seed = utf8Bytes("Café…"); // UTF-8 rodata
|
||||
const command = asciiBytes("app.refresh"); // guaranteed ASCII
|
||||
```
|
||||
|
||||
The transpiler folds every `asciiBytes` call at compile time; under node the same import runs as a plain function with the same result. Observing a `string`'s code units (`.length`, `s[i]`) is a taught error because UTF-16 and UTF-8 would disagree, and `+` concatenation is taught away because runtime string building needs a JS string heap the binary does not carry.
|
||||
The compiler folds both byte intrinsics at compile time; under node the same imports run as plain functions with the same result. `asciiBytes` fails with NS1064 when a literal/template contains non-ASCII and throws `RangeError` if called directly with such text under node. `utf8Bytes` encodes Unicode exactly like `TextEncoder`, including U+FFFD for lone surrogates. Observing a `string`'s code units (`.length`, `s[i]`) is a taught error because UTF-16 and UTF-8 would disagree, and `+` concatenation is taught away because runtime string building needs a JS string heap the binary does not carry.
|
||||
|
||||
Bytes still read like text: the everyday string methods work directly on `Uint8Array` values, with **byte-honest semantics** — every length, offset, and index is a BYTE length/offset (never a character count: `é` measures 2), search is byte-wise, and case mapping is Unicode simple case mapping (code point to code point from the Unicode tables, locale-free, no special casing — `ß` stays `ß`; invalid UTF-8 passes through unchanged). The native build lowers each call onto the runtime kernel and node runs the same methods from the same generated tables, so both produce identical bytes by construction.
|
||||
Bytes still read like text: the everyday string methods work directly on `Uint8Array` values, with **byte-honest semantics** — every length, offset, and index is a BYTE length/offset (never a character count: `é` measures 2), search is byte-wise, and case mapping is Unicode simple case mapping (code point to code point from the Unicode tables, locale-free, no special casing — `ß` stays `ß`; invalid UTF-8 passes through unchanged). The compiled core and node run the same methods from the same generated tables, so both produce identical bytes by construction.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
@@ -282,10 +288,30 @@ The runtime interprets the command after the model commits and dispatches any re
|
||||
<td><code>Cmd.fetch(spec, { key?, ok, err })</code></td>
|
||||
<td>A buffered HTTP(S) exchange; <code>ok</code> carries <code>{ status, body }</code> (a 404 is still <code>ok</code> — a delivered response), <code>err</code> the transport reason</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.fetch(spec, { key?, line, ok, err })</code></td>
|
||||
<td>A line-streamed HTTP(S) exchange for SSE/NDJSON; each <code>line</code> carries bytes as it arrives, then <code>ok</code> carries the terminal HTTP status or <code>err</code> the transport reason</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.clipboardWrite(bytes)</code> / <code>Cmd.clipboardRead({ key?, ok, err })</code></td>
|
||||
<td>System clipboard: write is fire-and-forget, read routes the text bytes back</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.showNotification({ title, subtitle?, body? })</code></td>
|
||||
<td>Show a desktop notification, fire-and-forget; text fields are bytes and the OS remains authoritative over final delivery</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.openExternalUrl(url)</code> / <code>Cmd.revealPath(path)</code></td>
|
||||
<td>Open an allowed HTTP(S) URL in the system browser or reveal a path in Finder/Files/Explorer; both are fire-and-forget and fail closed</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.credentials.set(...)</code> / <code>Cmd.credentials.get(...)</code> / <code>Cmd.credentials.delete(...)</code></td>
|
||||
<td>App-scoped access to the OS credential store; get returns secret bytes, missing items route <code>miss</code>, and the manifest must declare the <code>credentials</code> capability and permission</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.formatLocalTime(timestampMs, style, route)</code></td>
|
||||
<td>Format an epoch timestamp as localized <code>date</code>, <code>time</code>, or <code>datetime</code> text in the host's current time zone</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.spawn(argv, { key?, stdin?, line?, exit, err })</code></td>
|
||||
<td>Run a subprocess, streaming stdout line by line; <code>collect: true</code> buffers whole stdout into the <code>exit</code> arm instead</td>
|
||||
@@ -295,8 +321,16 @@ The runtime interprets the command after the model commits and dispatches any re
|
||||
<td>The audio player: one event stream (<code>loaded</code>, <code>position</code>, <code>completed</code>, <code>failed</code>, <code>spectrum</code>, ...) until <code>audioStop</code> closes it</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.showWindow(label)</code> / <code>Cmd.quitApp()</code></td>
|
||||
<td>The menu-bar lifecycle verbs: un-hide + activate the labeled window (the tray "Open" consequence, the counterpart to <code>close_policy = "hide"</code>), and the real graceful terminate</td>
|
||||
<td><code>Cmd.showWindow(label)</code> / <code>Cmd.hideWindow(label)</code> / <code>Cmd.quitApp()</code></td>
|
||||
<td>The menu-bar lifecycle verbs: show or retain-but-hide the labeled window, and gracefully terminate the app</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.setDockPresence(visible)</code></td>
|
||||
<td>Switch macOS between regular Dock/app-switcher presence and accessory/headless behavior; unsupported hosts ignore it</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.launchAtLoginStatus(route)</code> / <code>Cmd.setLaunchAtLogin(enabled, route)</code></td>
|
||||
<td>Query or change the installed app bundle's <code>SMAppService</code> registration; the ok bytes name <code>enabled</code>, <code>disabled</code>, <code>requires_approval</code>, or <code>not_found</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.imageLoad(id, source, { event })</code> + <code>imageCancel(id)</code>/<code>imageUnregister(id)</code></td>
|
||||
@@ -306,6 +340,22 @@ The runtime interprets the command after the model commits and dispatches any re
|
||||
<td><code>Cmd.channelOpen(key, { event })</code> / <code>channelClose(key)</code></td>
|
||||
<td>Open an external-source channel under an app-chosen numeric key: the native side holds the posting handle and feeds bytes from its own threads, and every post arrives through the one <code>event</code> arm with the back-pressure counters aboard. <code>channelClose</code> flushes staged posts, dispatches exactly one <code>closed</code> event with the final drop totals, and frees the key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.audioCaptureStart(key, spec, { event })</code> / <code>audioCaptureStop(key)</code></td>
|
||||
<td>Capture <code>microphone</code> or <code>system</code> audio as bounded, timestamped, interleaved signed-16 LE PCM chunks. The stream reports <code>started</code>, <code>data</code>, <code>failed</code>, <code>stopped</code>, and <code>rejected</code>, with observable drop counters</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.persist()</code></td>
|
||||
<td>Snapshot the just-committed Model through the engine-owned, capability-gated atomic store; restore arrives through the manifest's configured boot Msg route — see <a href="/docs/persistence">Model Persistence</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.store.set/get/delete/scan/setMany</code></td>
|
||||
<td>Persist independent byte records in the engine-owned, capability-gated record store; every result returns through the declared Msg route — see <a href="/docs/record-store">Record Store</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.db.query(sql, params, route)</code> / <code>Cmd.db.exec(statements, route)</code></td>
|
||||
<td>Run read-only relational queries as bounded row pages or commit a statement list atomically through the engine-owned SQLite database — see <a href="/docs/sqlite">Relational SQLite</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Cmd.host(name, ...args)</code> / <code>Cmd.request(name, payload, { key?, ok, err })</code></td>
|
||||
<td>App-defined host commands by literal name: fire-and-forget, or routed with exactly one result Msg back</td>
|
||||
@@ -317,51 +367,15 @@ The runtime interprets the command after the model commits and dispatches any re
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Result arms are ordinary Msg arms with the shape the effect produces — one `Uint8Array` field for host results and errors, one number field for timer fires, no fields for `writeFile`'s ok, one number plus one `Uint8Array` field for `fetch`'s — and tsc checks the shapes for you. Keys carry ONE in-flight discipline: a keyed effect — `Cmd.request`, the named engine ops, `Cmd.delay` — whose key is already in flight replaces the old one (the superseded result is dropped, no message; the debounce shape), and `Cmd.cancel` drops it silently. The one exception is a live `Cmd.spawn` key, which rejects the duplicate (`err` gets `rejected`) — a running subprocess is never killed implicitly; cancel it first, and that cancel is loud (`err` gets `cancelled`) because killing a process is an observable event. Every `err` arm receives a machine-readable reason, so failure is never silence. Persistence today is `Cmd.writeFile` + a boot-time `Cmd.readFile` — the pattern every real app uses.
|
||||
Result arms are ordinary Msg arms with the shape the effect produces — one `Uint8Array` field for raw host results and errors, a generated service result record, one number field for timer fires and a streaming fetch's terminal status, no fields for `writeFile`'s ok, one number plus one `Uint8Array` field for a buffered fetch's result — and tsc checks the shapes for you. Keys carry ONE in-flight discipline: a keyed engine effect — buffered named engine ops, `Cmd.delay`, a raw `Cmd.request` to an embedder host command — whose key is already in flight replaces the old one (the superseded result is dropped, no message; the debounce shape), and `Cmd.cancel` drops it with no message. Live `Cmd.spawn`, streaming-fetch, and service keys (buffered or streaming) reject a duplicate (`err` gets `rejected`) so two calls can never splice into one result or stream; cancel first if you mean to supersede — cancelling a buffered service request produces no message, while cancelling a live stream routes `cancelled` to `err`. A streaming fetch whose line is cut or dropped also ends with `err: truncated`, never a misleading successful status. Every routed `err` arm receives a machine-readable reason.
|
||||
|
||||
The debounced-autosave shape, in full — edits re-arm a one-shot; one write lands after the pause:
|
||||
Platform state stays on the same effect boundary. `Cmd.openExternalUrl(url)` enforces [`security.navigation.external_links`](/docs/security#external-links) before entering the browser; `Cmd.revealPath(path)` uses the desktop file manager. Credential operations take byte `service` and `account` identifiers plus the standard `{ key?, ok, err }` route: set/delete return empty bytes on `ok`, get returns the secret, and a missing item routes `not_found`. `Cmd.formatLocalTime(timestampMs, "date" | "time" | "datetime", route)` returns localized UTF-8 bytes using the current host locale and time zone. That formatting is deliberately a Cmd—not a pure helper—so session recording captures the observed text and replay never re-reads ambient locale or timezone state.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
Durable in-memory state uses `Cmd.persist()`: declare the `persist` capability, configure the boot routes and schema version, then return the command beside the committed model. The engine owns canonical serialization, trailing-edge coalescing, atomic app-data placement, backup recovery, migration, and journal/replay. See [Model Persistence](/docs/persistence) for the complete setup. `Cmd.readFile` and `Cmd.writeFile` remain for user-visible files, exports, and blobs; when using them, request the framework-provided app-data directory through `envMsgs` instead of depending on process cwd.
|
||||
|
||||
export interface Model {
|
||||
readonly draft: Uint8Array;
|
||||
readonly dirty: boolean;
|
||||
}
|
||||
Independent byte records use `Cmd.store`: declare the `store` capability, then route set/get/delete/scan/setMany results back to Msg arms. The engine owns the app-data path, SQLite schema, atomic batches, pagination, and replay boundary. See [Record Store](/docs/record-store).
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "draft_edit"; readonly text: Uint8Array }
|
||||
| { readonly kind: "save_now"; readonly at: number }
|
||||
| { readonly kind: "saved" }
|
||||
| { readonly kind: "save_failed"; readonly reason: Uint8Array };
|
||||
|
||||
export const viewUnbound = ["save_now", "saved", "save_failed", "dirty"] as const;
|
||||
|
||||
export function initialModel(): Model {
|
||||
return { draft: new Uint8Array(0), dirty: false };
|
||||
}
|
||||
|
||||
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "draft_edit":
|
||||
return [
|
||||
{ ...model, draft: msg.text, dirty: true },
|
||||
Cmd.delay("autosave", 800, "save_now"),
|
||||
];
|
||||
case "save_now":
|
||||
return [
|
||||
model,
|
||||
Cmd.writeFile(asciiBytes("draft.bin"), model.draft, { key: "save", ok: "saved", err: "save_failed" }),
|
||||
];
|
||||
case "saved":
|
||||
return { ...model, dirty: false };
|
||||
case "save_failed":
|
||||
return model;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
External sources — sockets, file watchers, native worker threads — reach `update` through a channel. `Cmd.channelOpen(key, { event })` opens a long-lived stream under an app-chosen numeric key, and every event dispatches the one `event` arm as a five-field record; `state` must be a named string-literal-union alias carrying exactly the three members — a narrower union would silently drop states the host emits, so the build refuses it. Posting is not a TS verb: transpiled cores are single-threaded by design, so the posting handle lives on the native side (`Effects.channelHandle(key)`), where embedders and platform-services extensions post bytes from their own threads. Back-pressure is honest — posts the native handle refused count into `droppedPending`/`droppedTotal` on the next delivered event, never silence — and a duplicate open on a live key dispatches `rejected`. `Cmd.channelClose(key)` ends the stream: staged posts flush, exactly one `closed` event carries the final totals, and the key frees.
|
||||
External sources — sockets, file watchers, native worker threads — reach `update` through a channel. `Cmd.channelOpen(key, { event })` opens a long-lived stream under an app-chosen numeric key, and every event dispatches the one `event` arm as a five-field record; `state` must be a named string-literal-union alias carrying exactly the three members — a narrower union would silently drop states the host emits, so the build refuses it. Posting is not a TS verb: compiled cores are single-threaded by design, so the posting handle lives on the native side (`Effects.channelHandle(key)`), where embedders and platform-services extensions post bytes from their own threads. Back-pressure is honest — posts the native handle refused count into `droppedPending`/`droppedTotal` on the next delivered event, never silence — and a duplicate open on a live key dispatches `rejected`. `Cmd.channelClose(key)` ends the stream: staged posts flush, exactly one `closed` event carries the final totals, and the key frees.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { Cmd } from "@native-sdk/core";
|
||||
@@ -411,6 +425,49 @@ export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
||||
}
|
||||
```
|
||||
|
||||
Audio input uses the same bounded, wake-driven stream transport without requiring native posting code. `Cmd.audioCaptureStart(key, { source, sampleRate?, channels? }, { event })` captures the microphone or the desktop output mix. The supported canonical rates are 16, 24, and 48 kHz; channels are mono or stereo; the default is 48 kHz mono. Each `data` event carries at most 20 ms of interleaved signed 16-bit little-endian PCM in `pcm`, plus `timestampMs`, `frames`, the delivered format, and drop counters. Microphone and system capture can run concurrently, but only one stream per source is live; starting that source again stops the prior key. `Cmd.audioCaptureStop(key)` quiesces the native callback, drains accepted chunks, then emits one `stopped` terminal. A key remains occupied until that terminal is delivered, so wait for `stopped` before reusing it. Add `"microphone"` and/or `"system_audio"` to `app.zon` permissions so packaged macOS apps receive the required usage descriptions and consent prompts.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { Cmd, type AudioCaptureState, type AudioCaptureSource } from "@native-sdk/core";
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "record" }
|
||||
| { readonly kind: "stop" }
|
||||
| { readonly kind: "audio_chunk"; readonly key: number; readonly state: AudioCaptureState; readonly source: AudioCaptureSource; readonly sampleRate: number; readonly channels: number; readonly timestampMs: number; readonly frames: number; readonly pcm: Uint8Array; readonly droppedPending: number; readonly droppedTotal: number };
|
||||
|
||||
// In update:
|
||||
// case "record": return [model, Cmd.audioCaptureStart(1, { source: "microphone", sampleRate: 48000, channels: 1 }, { event: "audio_chunk" })];
|
||||
// case "stop": return [model, Cmd.audioCaptureStop(1)];
|
||||
```
|
||||
|
||||
## Model-derived menu-bar status items
|
||||
|
||||
A `src/core.ts` app can own its complete native menu-bar item without custom Zig wiring. Export `statusItem(model): StatusItemState`; the generated launcher installs its icon, tooltip, click/open commands, presentation, and rows from the boot model, then re-derives all of them after committed updates. It patches shell, presentation, and menu independently and never recreates the item just because model state changed.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { asciiBytes, utf8Bytes } from "@native-sdk/core";
|
||||
import { type StatusItemState } from "@native-sdk/core/events";
|
||||
|
||||
export function statusItem(model: Model): StatusItemState {
|
||||
return {
|
||||
iconPath: asciiBytes("assets/menu-bar.svg"),
|
||||
tooltip: utf8Bytes("Sync status"),
|
||||
activationCommand: asciiBytes("app.sync"),
|
||||
alternateActivationCommand: asciiBytes(""),
|
||||
openCommand: asciiBytes("app.sync"),
|
||||
presentation: { title: model.syncing ? utf8Bytes("SYNC…") : utf8Bytes("READY"), width: 62, tone: model.failed ? "critical" : "normal", iconOpacity: model.stale ? 0.5 : 1, monospaced: true },
|
||||
items: [
|
||||
{ id: 1, label: utf8Bytes("Open"), command: asciiBytes("app.open"), separator: false, enabled: true, detail: asciiBytes(""), role: "command", key: asciiBytes(""), modifiers: { primary: false, command: false, control: false, option: false, shift: false } },
|
||||
{ id: 2, label: utf8Bytes("Sync now…"), command: asciiBytes("app.sync"), separator: false, enabled: !model.syncing, detail: asciiBytes(""), role: "command", key: asciiBytes("r"), modifiers: { primary: true, command: false, control: false, option: false, shift: false } },
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Import the canonical records and unions from `@native-sdk/core/events`. Presentation includes byte `title`, numeric `width`, `normal | warning | critical` tone, `iconOpacity` in 0…1, and `monospaced`. Rows include id/label/command/separator/enabled plus secondary `detail`, semantic `role`, key equivalent, and all five modifier booleans. Actionable ids are unique and non-zero, and there are at most 32 rows. `commandMsg(name): Msg | null` maps row selection, status-button activation, Option-activation, and menu-open refresh into the ordinary update loop. See [System Tray](/docs/tray) and the zero-Zig `examples/menu-bar` app for the full hide/Open/Quit lifecycle.
|
||||
|
||||
Export `statusItems(model): readonly StatusItemDescriptor[]` when the app needs several independent items. Each descriptor adds stable non-zero `id` identity and a live `visible` flag to the same shell/presentation/menu record. Adding/removing descriptors creates/removes only those ids; icon, title, tooltip, visibility, activation/open commands, and menu changes patch in place. Export either the singular or collection helper, not both. macOS supports up to eight simultaneous items; every item keeps its own 32-row menu.
|
||||
|
||||
## Subscriptions are Sub data
|
||||
|
||||
Recurring effects are declared, not issued: export `subscriptions(model): Sub<Msg>` and return descriptors derived from the current model. After every commit the host reconciles the returned set against its active timers by key — a new key (or a changed interval) arms a timer, a missing key cancels it — so starting, stopping, and re-tuning timers is just returning different data:
|
||||
@@ -455,7 +512,7 @@ export function subscriptions(model: Model): Sub<Msg> {
|
||||
}
|
||||
```
|
||||
|
||||
Keep the Sub-vs-stream line straight: a Sub is declarative — derived from the model, started and stopped by reconciliation, never opened or closed by the app. The multi-result streams (`Cmd.spawn`'s lines, `Cmd.audioPlay`'s events, `Cmd.channelOpen`'s posts) are Cmd-initiated — imperative opens with a keyed lifecycle the app drives. If the effect should exist exactly while some model state holds, it wants a Sub; if the app decides when it starts and ends, it is a stream.
|
||||
Keep the Sub-vs-stream line straight: a Sub is declarative — derived from the model, started and stopped by reconciliation, never opened or closed by the app. The multi-result streams (`Cmd.fetch`'s response lines, `Cmd.spawn`'s stdout lines, `Cmd.audioPlay`'s events, `Cmd.channelOpen`'s posts, and audio capture chunks) are Cmd-initiated — imperative opens with a keyed lifecycle the app drives. If the effect should exist exactly while some model state holds, it wants a Sub; if the app decides when it starts and ends, it is a stream.
|
||||
|
||||
## Text input from markup
|
||||
|
||||
@@ -463,7 +520,7 @@ A markup text control (`<text-field text="{draft}" on-input="draft_edit" />`) ne
|
||||
|
||||
## Splitting a core into modules
|
||||
|
||||
A core that outgrows one file splits into modules under `src/`: relative imports spelled with their real filenames (`./parsers.ts` — the same file runs under node, whose loader resolves real files), `src/` as the hard boundary (`../` and npm packages are teaching errors), and no runtime cycles (`import type` back-edges are fine and idiomatic — a helper module typically type-imports `Model` from the entry). Export lists and value re-exports are ordinary module surface: `export { helper, doneCount as remaining }` binds names over existing declarations, and `export { parsePs } from "./parsers.ts"` forwards another module's export by name — what stays out is `export default`, `export =`, and `export * from` (the flat emitted namespace resolves by name, so every export names what it binds). `core.ts` stays the entry module and the app's public face: `update`, `initialModel`, `subscriptions`, the wiring channels, and the exported binding helpers live there (declared and exported under their own names — a rename or re-export cannot bind an entry point), and imported modules hold the machinery they call. The SDK also ships library modules in the same subset — `@native-sdk/core/text` is the byte-splice text engine (caret, selection, IME composition, ASCII case-insensitive compare), and `@native-sdk/core/events` is the canonical event record types (`TextInputEvent` re-exported, `ScrollState`, `FrameEvent`, `KeyEvent`, `PinchPhase`/`PinchEvent`, `ColorScheme`, the chrome records, `AudioState`/`AudioEvent`) so no core re-types the vocabulary — transpiled into your core when imported and absent when not. Everything still emits as ONE readable native module, one section per source file.
|
||||
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.
|
||||
|
||||
<CodeToggle>
|
||||
|
||||
@@ -490,24 +547,50 @@ pub fn parseSample(bytes: []const u8) ?Sample { ... }
|
||||
|
||||
</CodeToggle>
|
||||
|
||||
## TypeScript services
|
||||
|
||||
The core is the app's deterministic logic — `Model`, `Msg`, `update`; services do the app's imperative work. A service operation is a directly exported, non-default named synchronous function under `src/services/`, taking zero or one explicitly typed request and declaring a contract-encodable result. Crossing shapes live in an exported, subset-legal module outside `src/services/` so the core and service import one declaration. The operation name is `<module-basename>.<export>`; `native check` projects its complete type table into `services.contract.json`, checks both classes, and generates the typed core client:
|
||||
|
||||
```ts:src/core.ts
|
||||
import { feedsParse } from "@native-sdk/services";
|
||||
|
||||
case "parse":
|
||||
return [
|
||||
model,
|
||||
feedsParse({ source: model.source, caseSensitive: false }, {
|
||||
key: "parse",
|
||||
ok: "parsed", // one ParseResult field
|
||||
err: "parse_failed", // one Uint8Array field
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
The core never receives a synchronous handle. Its update returns a command, the typed result crosses back as the named Msg arm, and the runtime journals that result like every other effect. Replay parks the request and feeds the recorded result without starting the carrier. The service itself is ordinary static-tier TypeScript — Node built-ins, `fetch`, regexes, JSON, `Map`/`Set`, `Date`, classes — running with the app's privileges on a supervised, lazily started carrier: a sibling child process with a sanitized environment by default, or an explicitly selected in-process worker-thread pool. Writing operations, kind-tagged error throws, streaming and cancellation, exact vendored npm, and the boundary rules NS1065–NS1067 have their own chapter: [TypeScript Services](/docs/typescript/services).
|
||||
|
||||
## The dev loop
|
||||
|
||||
`native dev --core` is the fastest loop for logic work: the core runs under node with a virtual host — dispatch Msgs as JSON lines (`{"kind":"add"}`, `{"$bytes":"…"}` for bytes payloads), advance a virtual clock (`{"advance":1000}`) to fire timers deterministically, and watch the committed model and effect transcript. Effects the virtual host does not perform (files, fetch, spawn) print as `cmd ...` lines — feed their results back yourself as ordinary Msg lines; that is the point, results are plain messages. Pair `--script msgs.ndjson` with `--watch` to replay a scenario on every edit. [Quick Start](/docs/quick-start#the-fastest-loop-the-core-under-node) shows a full transcript.
|
||||
`native dev --core` is the fastest loop for logic work: the core runs under Node with a virtual host — dispatch Msgs as JSON lines (`{"kind":"add"}`, `{"$bytes":"…"}` for bytes payloads), advance a virtual clock (`{"advance":1000}`) to fire timers deterministically, and watch the committed model and effect transcript. Service requests run in an isolated Node worker through the same generated contract: vendored hashes are verified, request/results use the same codecs and error arms, cooperative cancellation/deadlines interrupt CPU-bound work, and stream chunks use the same channel-event shape. Pair `--script msgs.ndjson` with `--watch` to replay a scenario on every edit. The devhost also consumes `NATIVE_SDK_SESSION_RECORD`/`NATIVE_SDK_SESSION_REPLAY` (the environment set by `native automate record|replay`): it writes the native journal format, and replay starts no service worker. Service-only recordings cross between it and the packaged runtime; packaged recordings containing other effect families use `native automate replay`, and devhost rejects those records explicitly. `native dev` runs compiled services for real beside the native app. [Quick Start](/docs/quick-start#the-fastest-loop-the-core-under-node) shows a full transcript.
|
||||
|
||||
`native check` runs the subset checker (real tsc semantics plus the app-core rules) over `src/core.ts` and its whole import graph (diagnostics carry each module's own path), then validates markup and `app.zon`. Every diagnostic names the rule, the idiomatic rewrite, and the reason — write to them up front and the loop stays fast.
|
||||
`native dev` keeps markup instant — `.native` edits hot-reload into the running window — but a `src/core.ts` edit rebuilds the core through the external core compiler and restarts the app: seconds per rebuild (roughly 3-6s warm), not sub-second. The core loop in the real window is restart-shaped; keep logic iteration under `native dev --core` and rebuild when you want to see it live.
|
||||
|
||||
`native check` runs the subset checker (real tsc semantics plus the app-core rules) over the core class, emits and validates the service contract, runs the pinned compiler's coverage verdict over each independent service root, then validates markup and `app.zon`. Every diagnostic names the rule, the idiomatic rewrite, and the reason — write to them up front and the loop stays fast.
|
||||
|
||||
## Build targets
|
||||
|
||||
Builds compile everything in the app — the core archive, any service executables or in-process archives, and the runner — for one stated target. The default is the build host; `-Dtarget` selects a cross desktop target following the pinned compiler's build matrix: Linux and Windows GNU targets build from any macOS, Linux, or Windows host, and macOS targets build on a macOS host (Apple linking needs the host toolchain's SDK). A Windows MSVC target builds natively on a matching Windows host; cross-Windows builds use the GNU ABI because Zig supplies that target's CRT and system libraries. An explicitly spelled Linux `-gnu` target also states its glibc version — `x86_64-linux-gnu.2.36` or later, or `x86_64-linux-musl` — because the compiled runtime needs glibc 2.36+ (a bare `-gnu` spelling lands on Zig's older default floor and is refused with the same teaching). The executable name and packaging follow the target OS. 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.
|
||||
|
||||
## Editor support
|
||||
|
||||
Editor support is stock tsc — no extension, no plugin. The scaffold ships `package.json` and `tsconfig.json` as the editor-and-versioning surface: the tsconfig mirrors the compiler options the checker itself builds its program with (strict, `moduleResolution: "bundler"`, `verbatimModuleSyntax`, `exactOptionalPropertyTypes`, …), so what your editor flags is what `native check` flags, and `@native-sdk/core` (plus subpaths like `@native-sdk/core/text`) resolves through `node_modules` like any package. Until `@native-sdk/core` is published to npm, the CLI materializes that `node_modules` copy itself — exactly the files the published package will contain — and `native check`/`dev`/`build` keep it fresh against the SDK (`native doctor` reports skew). After the publish, a plain `npm install` writes identical content and takes over. None of it is build truth: builds transpile against the SDK the CLI ships with and never read `node_modules` — delete it and every `native` verb still works.
|
||||
Editor support is stock tsc — no extension, no plugin. The scaffold ships `package.json` and `tsconfig.json` as the editor-and-versioning surface: the tsconfig mirrors the compiler options the checker itself builds its program with (strict, `moduleResolution: "bundler"`, `verbatimModuleSyntax`, `exactOptionalPropertyTypes`, …), so what your editor flags is what `native check` flags, and `@native-sdk/core` (plus subpaths like `@native-sdk/core/text`) resolves through `node_modules` like any package. Apps with services also receive an ignored `node_modules/@native-sdk/services` editor package whenever `native check` or `native dev --core` regenerates the typed client; authored `src/` stays clean. Until `@native-sdk/core` is published to npm, the CLI materializes that package copy itself — exactly the files the published artifact will contain — and `native check`/`dev`/`build` keep it fresh against the SDK (`native doctor` reports skew). After the publish, a plain `npm install` writes identical core content and takes over. None of it is build truth: builds check and compile against the SDK the CLI ships with and never read `node_modules` — delete it and every `native` verb still works.
|
||||
|
||||
## Reading the output: the eject story
|
||||
## Outgrowing the subset
|
||||
|
||||
The transpiler emits ordinary, readable Zig — your names are your names (fields, helpers, and locals keep their TS spellings), your switch arms become tagged-union switches, one commented module. `native check` leaves the latest emission in `.native/check/core.zig`, and the transpiler CLI writes it wherever you point `-o`. If an app outgrows the subset, that emitted module is the migration path: adopt it as handwritten source (the [App Model](/docs/app-model) page covers the Zig wiring) and keep building — nothing about the runtime changes, because the emitted core was already an ordinary Zig app core.
|
||||
The compiled core is a native static archive, not generated source: `native check` checks and leaves nothing behind, and there is no emitted Zig to read or adopt. If logic needs ambient APIs or ordinary static-tier TypeScript, keep deterministic state transitions in the core and move that work into `src/services/`. Port the core to Zig only when the logic tier itself needs capabilities outside both TypeScript classes; the [App Model](/docs/app-model) page covers that wiring.
|
||||
|
||||
## Where the subset ends
|
||||
|
||||
The core tier covers app logic. The toolkit-extension tier — custom widgets, rasterizer work, host services, platform integration — is Zig by design: that layer is the machinery itself, and [Building Components](/docs/building-components) is its guide. A TypeScript app that needs one custom widget does not switch tiers wholesale; the widget is Zig, the core stays TypeScript.
|
||||
The core owns app state and decisions — the app's deterministic logic. Services own imperative application work in ordinary TypeScript: parsing, filesystem transforms, environment inspection, subprocesses, and other ambient operations whose results cross back as messages. The toolkit-extension tier — custom widgets, rasterizer work, new engine-owned effects, and platform integration — remains Zig by design: that layer is the machinery itself, and [Building Components](/docs/building-components) is its guide. Services are not a backdoor storage engine or general FFI surface.
|
||||
|
||||
## Reference
|
||||
|
||||
The complete authoring guide — every rule ID, every Cmd shape, every subset corner with its idiom — ships as the `ts-core` agent skill: `native skills get ts-core`. It is written for AI agents and precise enough for humans.
|
||||
The complete core guide ships as `native skills get ts-core`; typed service contracts, vendored npm, streaming, authority, and transport limits ship as `native skills get ts-services`. Both are written for AI agents and precise enough for humans.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata";
|
||||
|
||||
export const metadata = pageMetadata("typescript/services");
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
# TypeScript Services
|
||||
|
||||
Modules under `src/services/` are ordinary TypeScript compiled to native code on the compiler's full static tier: `fs`, `path`, `process`, `os`, `child_process`, `fetch`, regexes, `JSON`, `Map`/`Set`, `Date`, and classes, when the pinned compiler supports them. The same pinned compiler builds the deterministic core ([TypeScript Cores](/docs/typescript)) and the services; no JavaScript engine ships in either.
|
||||
|
||||
The core calls a service by returning a command from `update`. The typed result returns as an ordinary `Msg`:
|
||||
|
||||
```ts:src/core.ts
|
||||
import { feedsParse } from "@native-sdk/services";
|
||||
|
||||
case "parse":
|
||||
return [model, feedsParse({ source: model.source, caseSensitive: false }, {
|
||||
key: "parse",
|
||||
ok: "parsed", // the one Msg arm carrying ParseResult
|
||||
err: "parse_failed", // a one-Uint8Array-field arm
|
||||
})];
|
||||
```
|
||||
|
||||
Services run on a supervised carrier — 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)).
|
||||
|
||||
## The two roles
|
||||
|
||||
The split is by role: the core is the app's deterministic logic — `Model`, `Msg`, `update` — and services do the app's imperative work. Record→replay, headless testing, and [automation](/docs/automation) depend on `update` being a pure function of its inputs. A service reads the real filesystem, clock, and network, so the checker refuses a core import of a service file (NS1065) and the core-to-service edge is always a command. Service results are journaled like every other effect result.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th>Owns</th>
|
||||
<th>Language rules</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Core (<code>src/core.ts</code> + imports outside <code>src/services/</code>)</td>
|
||||
<td>App state and decisions: <code>Model</code>, <code>Msg</code>, <code>update</code>, pure helpers</td>
|
||||
<td>The deterministic subset (NS1001–NS1064)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Service (<code>src/services/**/*.ts</code>)</td>
|
||||
<td>Imperative work: parsing, filesystem transforms, environment inspection, subprocesses</td>
|
||||
<td>Ordinary static-tier TypeScript; only the boundary rules NS1065–NS1067 apply</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Services are not a storage engine or a general FFI surface. Durable data uses the engine-owned [persistence](/docs/persistence) and [record store](/docs/record-store) effects; custom widgets, render passes, and new engine capabilities are Zig ([Building Components](/docs/building-components)).
|
||||
|
||||
## Service authority
|
||||
|
||||
A service runs with the app's privileges. Its working directory is the app data directory. It may use:
|
||||
|
||||
- **Filesystem** — Node built-ins over the real disk.
|
||||
- **Environment** — `process` and the allowlisted variables below.
|
||||
- **Network** — `fetch` and sockets, directly.
|
||||
- **Ambient time and randomness** — `Date.now()`, `Math.random()`, and friends. Their results reach the core only as journaled message payloads.
|
||||
|
||||
The child process receives an explicit environment allowlist; everything else, including every `NATIVE_SDK_*` internal, is stripped.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th>Variables</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Path</td>
|
||||
<td><code>PATH</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Home / user / temp</td>
|
||||
<td><code>HOME</code>, <code>USER</code>, <code>TMPDIR</code>, <code>TMP</code>, <code>TEMP</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Locale / time zone</td>
|
||||
<td><code>LANG</code>, <code>LC_ALL</code>, <code>LC_CTYPE</code>, <code>TZ</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Certificates</td>
|
||||
<td><code>SSL_CERT_FILE</code>, <code>SSL_CERT_DIR</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Proxies</td>
|
||||
<td><code>HTTP_PROXY</code>, <code>HTTPS_PROXY</code>, <code>NO_PROXY</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Windows additions</td>
|
||||
<td><code>USERPROFILE</code>, <code>USERNAME</code>, <code>SystemRoot</code>, <code>COMSPEC</code>, <code>PATHEXT</code>; all names match case-insensitively</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Standard output carries the framed transport between app and service, so service diagnostics go to standard error.
|
||||
|
||||
## Writing a service
|
||||
|
||||
A service module is any `.ts` file under `src/services/`. Every directly exported, non-default named function is an operation:
|
||||
|
||||
- It is synchronous and has a body.
|
||||
- It takes zero or one explicitly annotated request parameter.
|
||||
- It declares a contract-encodable result type.
|
||||
- Its name is `<module-basename>.<export>` — `export function parse` in `src/services/feeds.ts` is `feeds.parse`.
|
||||
|
||||
Boundary shapes live in a shared, subset-legal module outside `src/services/`, imported by the core and the service:
|
||||
|
||||
```ts:src/shared.ts
|
||||
export type ParseRequest = {
|
||||
readonly source: Uint8Array;
|
||||
readonly caseSensitive: boolean;
|
||||
};
|
||||
|
||||
export type ParseResult = {
|
||||
readonly bytes: Uint8Array;
|
||||
readonly matches: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
```ts:src/services/feeds.ts
|
||||
import * as fs from "node:fs";
|
||||
import type { ParseRequest, ParseResult } from "../shared.ts";
|
||||
|
||||
export function parse(request: ParseRequest): ParseResult {
|
||||
if (!fs.existsSync(".")) {
|
||||
throw { kind: "data_directory_missing", message: "the app data directory is unavailable" };
|
||||
}
|
||||
const source = new TextDecoder().decode(request.source);
|
||||
const matches = request.caseSensitive ? /feed/.test(source) : /feed/i.test(source);
|
||||
return { bytes: new TextEncoder().encode(JSON.stringify({ matches })), matches };
|
||||
}
|
||||
```
|
||||
|
||||
`native check` projects the complete type table into a contract sidecar (`services.contract.json`), checks both classes, and generates the typed client the core imports. An operation shaped any other way — `async`, a default export, an unannotated request, a non-encodable result — is a teaching error (NS1067) naming the rewrite.
|
||||
|
||||
### Boundary types
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Crosses</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Booleans, numbers</td>
|
||||
<td>Integer-class fields are proven and carried as integers</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Uint8Array</code></td>
|
||||
<td>The bytes form the core and services already share</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Optionals, readonly slices</td>
|
||||
<td><code>T | null</code> and <code>readonly T[]</code> of encodable elements</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Named records, enums, kind-tagged unions</td>
|
||||
<td>Declared in the shared module; both sides import the one declaration</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Functions, behavior-bearing classes, Promises</td>
|
||||
<td>Do not cross — the boundary is encoded data, not object references</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Inside the service, classes, `Map`s, and the rest of the static tier are unrestricted; they just cannot be a request or result shape.
|
||||
|
||||
### Errors
|
||||
|
||||
An explicit throw crossing the operation boundary must be exactly an inline `{ kind: "...", message: "..." }` shape with a string-valued message, and it must escape the operation rather than be caught locally:
|
||||
|
||||
```ts
|
||||
throw { kind: "parse", message: "bad feed" };
|
||||
```
|
||||
|
||||
The encoded kind and message arrive on the core's error arm as UTF-8 JSON bytes. Do not throw `new Error(...)` from the exported surface. The build mechanically lowers the escaping tagged value into the form the pinned compiler carries across the boundary; your checked-in source — and its behavior under Node — does not change.
|
||||
|
||||
## Calling a service
|
||||
|
||||
`native check` derives the virtual module `@native-sdk/services` from the contract: one constructor per operation, named `<module><Export>` (`feeds.parse` → `feedsParse`), taking the typed request plus a route.
|
||||
|
||||
```ts:src/core.ts
|
||||
import { feedsParse } from "@native-sdk/services";
|
||||
import type { ParseRequest, ParseResult } from "./shared.ts";
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "parse"; readonly request: ParseRequest }
|
||||
| { readonly kind: "parsed"; readonly result: ParseResult }
|
||||
| { readonly kind: "parse_failed"; readonly error: Uint8Array };
|
||||
|
||||
case "parse":
|
||||
return [model, feedsParse(msg.request, {
|
||||
key: "feed-parse",
|
||||
ok: "parsed",
|
||||
err: "parse_failed",
|
||||
})];
|
||||
```
|
||||
|
||||
The route is typechecked: the constructor's type proves that `ok` names the one Msg arm carrying exactly the declared result record and that `err` names a one-bytes-field arm. A stale field or wrong route is a `native check` type error at the call site. The generated source lives only in build scratch space and the ignored editor package under `node_modules/@native-sdk/services`, never in authored `src/`.
|
||||
|
||||
Raw `Cmd.request("feeds.parse", bytes, { key?, ok, err })` remains the low-level byte seam beneath the client — same transport, same routing, request and result as raw bytes you encode yourself.
|
||||
|
||||
### Keys
|
||||
|
||||
Keys share the engine effect-key space. A second live request on the same key — buffered or streaming — is rejected (`err` receives `rejected`) rather than replacing the first, so two calls can never splice into one result. Cancel the first if you mean to supersede it.
|
||||
|
||||
### Timeouts
|
||||
|
||||
Every request carries a deadline: 30 seconds by default, or the operation's declared `@deadlineMs` (a JSDoc tag, 1 to 86400000 ms). Expiry routes JSON with `kind: "timeout"` to `err`.
|
||||
|
||||
### Cancellation
|
||||
|
||||
`Cmd.cancel(key)` on a buffered request drops it — no message is dispatched — and cooperatively interrupts the service child. Cancelling a stream routes `cancelled` to `err` (see [Streaming](#streaming)).
|
||||
|
||||
## Streaming
|
||||
|
||||
To return incremental results, declare a final typed `emit` capability. Each chunk arrives through a channel-event Msg arm; the function's return stays the one typed terminal result.
|
||||
|
||||
```ts:src/services/feeds.ts
|
||||
import type { ServiceCancellation } from "@native-sdk/core";
|
||||
import type { ParseChunk, ParseRequest, ParseResult } from "../shared.ts";
|
||||
|
||||
/**
|
||||
* @deadlineMs 5000
|
||||
* @streamBuffer 8
|
||||
*/
|
||||
export function parseLarge(
|
||||
request: ParseRequest,
|
||||
emit: (chunk: ParseChunk) => void,
|
||||
cancellation: ServiceCancellation,
|
||||
): ParseResult {
|
||||
for (let index = 0; index < request.source.length; index += 4096) {
|
||||
cancellation.throwIfCancelled();
|
||||
emit({ bytes: request.source.slice(index, index + 4096), index });
|
||||
}
|
||||
return parse(request);
|
||||
}
|
||||
```
|
||||
|
||||
The generated route gains two fields beside `key`, `ok`, and `err`: `channelKey` (an app-chosen numeric channel key) and `event` (the channel-event Msg arm each chunk dispatches). The terminal result closes the channel after all accepted chunks. `@streamBuffer` caps in-flight chunks at 1–64 (default 8).
|
||||
|
||||
### Cooperative cancellation
|
||||
|
||||
An optional final `ServiceCancellation` parameter opts an operation into cooperative cancellation — legal only as the last parameter. Poll `cancelled()` or call `throwIfCancelled()` at bounded intervals.
|
||||
|
||||
- `Cmd.cancel(key)` on a stream flips the token, closes the channel, routes `cancelled` to `err`, and drops every later chunk.
|
||||
- A deadline expiry flips the same token and routes `kind: "timeout"` to `err`.
|
||||
- The child gets a short grace period to unwind and stays alive when it cooperates. An operation that ignores its token is hard-killed, and the next request starts a clean host.
|
||||
|
||||
## npm packages
|
||||
|
||||
Service modules may import local service files, shared core-class declarations, and exact vendored npm packages — never a bare install:
|
||||
|
||||
```bash
|
||||
native vendor . escape-string-regexp@5.0.0
|
||||
```
|
||||
|
||||
The command resolves the exact version once (lifecycle scripts disabled), copies the flattened package graph and license files into `src/services/vendor/`, and writes the exact name/version/tree-hash facts into `app.zon`. Check both in. Builds are hermetic: no npm, no network — every vendored byte is re-hashed, and the compiler receives only the explicit declared package allowlist. Importing a package that was never vendored is NS1066:
|
||||
|
||||
> Run `native vendor . package@X.Y.Z`, check in `src/services/vendor/` and the generated app.zon `service_packages` facts, then import that exact package name; or vendor a local source module and import it relatively.
|
||||
|
||||
npm support is selective. A vendored package compiles only if the pinned compiler reaches 100% static coverage of its bytes; anything less fails `native check` with the compiler's coverage note preserved verbatim and a remediation. The shipped compiler's calibration run over five deliberately small candidates passed three and refused two:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Package</th>
|
||||
<th>Verdict</th>
|
||||
<th>Static coverage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>escape-string-regexp@5.0.0</code></td>
|
||||
<td>compiled</td>
|
||||
<td>100%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>comma-separated-tokens@2.0.3</code></td>
|
||||
<td>compiled</td>
|
||||
<td>100%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>space-separated-tokens@2.0.2</code></td>
|
||||
<td>compiled</td>
|
||||
<td>100%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>nanoid@3.3.15</code></td>
|
||||
<td>refused</td>
|
||||
<td>76%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>micromark@4.0.2</code></td>
|
||||
<td>refused</td>
|
||||
<td>92%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Small, source-shipping, dependency-light utilities are the realistic fit. There is no `auto` mode or dynamic fallback; `native check` is the verdict for the exact bytes you vendored, and a refusal names the options: choose another exact package, port or vendor a suitable implementation, or wait for broader compiler support. Source you control — your own modules under `src/services/` — compiles on the same tier with no coverage question. For npm-heavy work that does not compile statically (an editor component, a charting stack), use a different edge: see [Where Packages Go](/docs/typescript/packages).
|
||||
|
||||
## Runtime behavior
|
||||
|
||||
Two carriers run the same operations behind the same routes, keys, deadlines, cancellation, streaming, and replay semantics. The build selects one:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Carrier</th>
|
||||
<th>Where services run</th>
|
||||
<th>Selection</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>child</code></td>
|
||||
<td>A second native executable — <code><app>_services</code> — beside the app binary, packaged with it</td>
|
||||
<td>Unset/<code>auto</code> default; available on every supported desktop build</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>
|
||||
</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).
|
||||
|
||||
Shared guarantees:
|
||||
|
||||
- **Lazy start.** Nothing starts before the first real request — no child process, no pool thread. A session that never calls a service pays nothing.
|
||||
- **Verified pairing.** The child's startup handshake checks the protocol version and a fingerprint of the generated operation/type/package registry; the in-process carrier checks the same fingerprint against the linked archive. A mismatch rejects before any operation dispatches.
|
||||
- **Supervision.** Same-key requests run strictly FIFO. The in-process pool runs different keys in parallel across its instances; the child runs everything on one worker. A cancellation or deadline publishes the cooperative token and grants a short grace: an operation that returns inside it keeps its instance (or process) warm. Past the grace, the child is killed and respawns on the next request; the in-process carrier abandons the instance's thread, routes the failure, and adds a fresh instance to the pool. An abandoned dispatch keeps its key reserved until it physically stops, so a same-key replacement cannot overlap its side effects (and can itself expire while waiting). A detected trap poisons only the instance it fired in (`kind: "service_trap"`); other instances keep answering. Every failure produces a routed result: a dead transport `kind: "service_host"`, an expired deadline `kind: "timeout"`.
|
||||
- **Replay.** Terminal results and stream events are journaled like every other effect. Replaying a recorded session parks each request and feeds the recorded result; neither carrier starts anything.
|
||||
- **Scope.** 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.
|
||||
|
||||
In-process specifics:
|
||||
|
||||
- Service code shares the app process: its ambient authority is the app's own (no environment allowlist, the app's working directory), and a hardware fault in service code — a stack overflow above all — is process-wide. The child carrier remains the fully isolated option.
|
||||
- Each pool worker owns a separate instance of the service modules. Mutable module globals are worker-local, so different-key requests may observe different copies; keep shared durable state outside service-module globals.
|
||||
- An abandoned instance's memory is reclaimed only at process exit; each trap or ignored token costs one leaked instance.
|
||||
- `process.exit()` in service code exits the app.
|
||||
|
||||
## Development
|
||||
|
||||
`native dev --core` runs service operations in an isolated Node worker through the same generated contract: the same vendored-package hash verification, request/result codecs, error arms, cooperative cancellation and deadlines, and channel-event chunk shape. Pair `--script scenario.ndjson` with `--watch` for repeatable iteration.
|
||||
|
||||
The devhost honors session record/replay the same way the packaged runtime does — replay starts no service worker — and service-only recordings cross between the devhost and the packaged app. `native dev` runs the app with its build-selected carrier: the in-process pool linked into the binary, or the compiled service executable beside it.
|
||||
|
||||
## Boundary diagnostics
|
||||
|
||||
Three checker rules enforce the boundary. Each teaches the fix and the reason at the site.
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rule</th>
|
||||
<th>Teaching</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>NS1065</strong> — the core does not import services</td>
|
||||
<td>A direct import would run ambient, non-deterministic service authority inside update and erase the command/result boundary that journaling and replay depend on. The core-to-service edge is always an effect.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>NS1066</strong> — service package imports are exact vendored facts</td>
|
||||
<td>Service builds have no package-manager or network input: the compiler sees only manifest-declared, hash-verified checked-in sources through an explicit static-package allowlist.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>NS1067</strong> — service calls match the generated typed contract</td>
|
||||
<td>The host codecs, runner registry, and typed client are projections of <code>services.contract.json</code>; every crossing data shape, stream declaration, deadline, and operation name must be stated there once.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Reference
|
||||
|
||||
`examples/service-feed-reader` 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`.
|
||||
@@ -49,6 +49,18 @@ Canvas windows are created hidden and become visible after their first completed
|
||||
|
||||
On macOS these map to an alpha-capable floating `NSWindow`, ignored mouse events, and passive `orderFront`. The system WebView engine can participate in that alpha window; the Chromium engine rejects `transparent` because its windowed CEF browser cannot expose alpha to the parent window. Windows uses a layered topmost window with per-pixel alpha, transparent hit testing, a non-activating initial reveal, and normal activation if the user later clicks an interactive overlay. Its layered presenter composites multiple canvas surfaces in layer order. Because Win32 cannot redirect non-client chrome or child surfaces into the top-level alpha bitmap, a transparent Windows window must use `titlebar = .chromeless`, cannot be combined with application menus, and rejects WebViews and other native views. Linux provides transparency, input regions, and passive show on GTK; topmost is honored on X11 through `_NET_WM_STATE_ABOVE`. Wayland intentionally gives the compositor—not clients—control of topmost placement, so the GTK host reports that limitation and cannot guarantee `always_on_top` there.
|
||||
|
||||
## macOS menu-bar and login-item hooks
|
||||
|
||||
A TypeScript app does not need an AppKit sidecar for the usual menu-bar-app lifecycle:
|
||||
|
||||
- Set `initially_hidden = true` on the startup window to create its native host window ordered out. Unlike canvas present-before-show, no fallback deadline reveals it; `Cmd.showWindow(label)` or an explicit focus is required.
|
||||
- Set `allows_fullscreen = false` on a settings window to keep ordinary resizing while removing macOS native-fullscreen participation and disabling the green fullscreen/zoom affordance. The flag is fixed when the window is created and is available on `app.zon` windows and `UiApp.WindowDescriptor`.
|
||||
- Use `Cmd.hideWindow(label)` and `Cmd.showWindow(label)` to hide and restore a live window without destroying its views.
|
||||
- Use `Cmd.setDockPresence(false)` for an accessory/headless Dock policy and `Cmd.setDockPresence(true)` to return to a regular Dock/app-switcher app.
|
||||
- Use `Cmd.launchAtLoginStatus(route)` and `Cmd.setLaunchAtLogin(enabled, route)` for `SMAppService.mainApp`. The ok arm receives UTF-8 bytes containing `enabled`, `disabled`, `requires_approval`, or `not_found`; the err arm receives `unsupported`, `failed`, or `invalid_request`. `not_found` normally means the executable is not running from an installed app bundle, while `requires_approval` means the user must approve the item in System Settings.
|
||||
|
||||
The login-item calls are keyed effects, so their results record and replay like `Cmd.request`; Dock and window visibility changes are fire-and-forget commands. Older macOS versions without `SMAppService` report `unsupported` instead of requiring weak-linked application code.
|
||||
|
||||
## Creating windows from Zig
|
||||
|
||||
```zig
|
||||
|
||||
+44
-40
@@ -8,7 +8,8 @@ import { WindowDots } from "@/components/home/window-dots";
|
||||
import { githubUrl, siteName } from "@/lib/site";
|
||||
|
||||
// ---------------------------------------------------------------- samples
|
||||
// Both excerpts are real source from examples/ui-inbox in this repository.
|
||||
// The markup is excerpted from examples/ui-inbox. The core excerpt shows
|
||||
// the same loop in the default TypeScript authoring language.
|
||||
|
||||
const markupSample = `<column background="background">
|
||||
<row height="{header_height}" padding="12" gap="10" cross="center"
|
||||
@@ -47,37 +48,31 @@ const markupSample = `<column background="background">
|
||||
<status-bar>{openCount} open · {doneCount} done</status-bar>
|
||||
</column>`;
|
||||
|
||||
const zigSample = `pub const Msg = union(enum) {
|
||||
add,
|
||||
toggle: u32,
|
||||
set_filter: Filter,
|
||||
clear_done,
|
||||
draft_edit: canvas.TextInputEvent,
|
||||
chrome_changed: native_sdk.WindowChrome,
|
||||
};
|
||||
const tsSample = `export type Msg =
|
||||
| { readonly kind: "add" }
|
||||
| { readonly kind: "toggle"; readonly id: number }
|
||||
| { readonly kind: "set_filter"; readonly filter: Filter }
|
||||
| { readonly kind: "clear_done" }
|
||||
| { readonly kind: "draft_edit"; readonly edit: TextInputEvent };
|
||||
|
||||
pub fn update(model: *Model, msg: Msg) void {
|
||||
switch (msg) {
|
||||
.add => {
|
||||
if (model.draftEmpty()) {
|
||||
model.addGeneratedTask();
|
||||
} else {
|
||||
model.addTask(std.mem.trim(u8, model.draft(), " "));
|
||||
model.draft_buffer.clear();
|
||||
}
|
||||
},
|
||||
.toggle => |id| if (model.taskById(id)) |task| {
|
||||
task.done = !task.done;
|
||||
},
|
||||
.set_filter => |filter| model.filter = filter,
|
||||
.clear_done => model.clearDone(),
|
||||
.draft_edit => |edit| model.draft_buffer.apply(edit),
|
||||
.chrome_changed => |chrome| {
|
||||
model.chrome_leading = chrome.insets.left;
|
||||
model.header_height =
|
||||
@max(header_natural_height, chrome.insets.top);
|
||||
},
|
||||
}
|
||||
export function update(model: Model, msg: Msg): Model {
|
||||
switch (msg.kind) {
|
||||
case "add":
|
||||
return addTask(model);
|
||||
case "toggle":
|
||||
return {
|
||||
...model,
|
||||
tasks: model.tasks.map((task) =>
|
||||
task.id === msg.id ? { ...task, done: !task.done } : task,
|
||||
),
|
||||
};
|
||||
case "set_filter":
|
||||
return { ...model, filter: msg.filter };
|
||||
case "clear_done":
|
||||
return { ...model, tasks: model.tasks.filter((task) => !task.done) };
|
||||
case "draft_edit":
|
||||
return { ...model, draft: applyDraftEdit(model.draft, msg.edit) };
|
||||
}
|
||||
}`;
|
||||
|
||||
// ------------------------------------------------------------ small parts
|
||||
@@ -385,14 +380,14 @@ export default function HomePage() {
|
||||
<SectionLede>
|
||||
Events produce messages, messages update state, and state renders the interface —
|
||||
simple to debug, simple to maintain, and simple for AI to generate. This is{" "}
|
||||
<InlineCode>examples/ui-inbox</InlineCode> from the repository: the whole UI is one
|
||||
declarative view, and one update function is the only place state changes. Mistakes in
|
||||
a view are compile errors with line and column, and in dev you edit the view while the
|
||||
app runs, keeping state.
|
||||
the default authoring shape: the whole UI is one declarative view, and one TypeScript
|
||||
update function is the only place state changes. The TypeScript is compiled to native
|
||||
code; no JavaScript runtime ships with the app. Mistakes in a view are compile errors
|
||||
with line and column, and in dev you edit the view while the app runs, keeping state.
|
||||
</SectionLede>
|
||||
<div className="mt-10 grid gap-6 lg:grid-cols-2">
|
||||
<CodePane title="src/inbox.native" lang="html" code={markupSample} />
|
||||
<CodePane title="src/main.zig" lang="zig" code={zigSample} />
|
||||
<CodePane title="src/app.native" lang="html" code={markupSample} />
|
||||
<CodePane title="src/core.ts" lang="ts" code={tsSample} />
|
||||
</div>
|
||||
<figure className="mt-6">
|
||||
<div className="mx-auto max-w-4xl rounded-md border border-gray-alpha-400 bg-gradient-to-b from-gray-100 to-background-200 p-6 sm:p-8 dark:from-gray-alpha-100 dark:to-background-100">
|
||||
@@ -407,8 +402,8 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
<figcaption className="mx-auto mt-4 max-w-3xl text-center copy-14 text-gray-900">
|
||||
Built from the source above and captured running on macOS. The pixels come from{" "}
|
||||
{siteName}’s engine; the window and scroll physics come from the OS.
|
||||
The <InlineCode>ui-inbox</InlineCode> reference captured running on macOS. The pixels
|
||||
come from {siteName}’s engine; the window and scroll physics come from the OS.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
@@ -601,13 +596,19 @@ export default function HomePage() {
|
||||
<Muted>a real window opens — edit src/app.native while it runs</Muted>
|
||||
</Terminal>
|
||||
</div>
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/docs/quick-start"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-gray-1000 px-4 button-14 text-background-100 transition-colors hover:bg-gray-1000/85"
|
||||
>
|
||||
Quick Start
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/typescript"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border border-gray-alpha-400 bg-background-100 px-4 button-14 text-gray-1000 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
TypeScript Cores
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/native-ui"
|
||||
className="inline-flex h-10 items-center justify-center rounded-md border border-gray-alpha-400 bg-background-100 px-4 button-14 text-gray-1000 transition-colors hover:bg-gray-100"
|
||||
@@ -629,6 +630,9 @@ export default function HomePage() {
|
||||
<Link href="/docs/native-ui" className="transition-colors hover:text-gray-1000">
|
||||
Native UI
|
||||
</Link>
|
||||
<Link href="/docs/typescript" className="transition-colors hover:text-gray-1000">
|
||||
TypeScript
|
||||
</Link>
|
||||
<Link href="/docs/automation" className="transition-colors hover:text-gray-1000">
|
||||
Automation
|
||||
</Link>
|
||||
|
||||
@@ -483,7 +483,12 @@ export function ComponentPreviewLive({
|
||||
aria-label={`${alt} — interactive WASM preview`}
|
||||
aria-roledescription="Interactive component preview rendered by the Native SDK engine. Press Escape to leave."
|
||||
tabIndex={0}
|
||||
className={`absolute inset-0 h-full w-full touch-none outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-700 ${
|
||||
// DOM focus is the transport into the embedded engine, but the
|
||||
// first Tab into a preview arrives before an engine widget owns
|
||||
// focus. Keep an inset canvas outline for that entry state while
|
||||
// cancelling the docs-wide [tabindex] shadow, whose overflow can
|
||||
// otherwise appear as a stray stripe below the titlebar.
|
||||
className={`absolute inset-0 h-full w-full touch-none outline-none focus-visible:shadow-none focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-700 ${
|
||||
painted ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onPointerDown={(event) => {
|
||||
|
||||
@@ -214,11 +214,11 @@
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"doc": "Bare highlighted source content with no background, border, radius, shadow, or padding. source is one required text {binding}; language is a literal lexer name. Wraps by default, line-numbers opts into logical line numbers, wrap=\"false\" keeps lines intact, and a definite height makes overflow scrollable. Wrap in a panel or card when chrome is wanted."
|
||||
"doc": "Bare highlighted source content with no background, border, radius, shadow, or padding. source is one required text {binding}; language is a literal lexer name. Wraps by default, line-numbers opts into logical line numbers, added-lines/removed-lines add Geist-style diff rows, wrap=\"false\" keeps lines intact, and a definite height makes overflow scrollable. Wrap in a panel or card when chrome is wanted."
|
||||
},
|
||||
{
|
||||
"name": "markdown",
|
||||
"doc": "Renders a markdown string (GFM subset, pipe tables included) as widgets; source is one {binding}, links dispatch on-link (bare URLs autolink), <details> blocks toggle via on-details + details-expanded, #123 refs linkify via issue-link-base."
|
||||
"doc": "Renders a markdown string (GFM subset, pipe tables and safe presentational HTML included) as widgets; source is one {binding}, preloaded leading images map through images, links dispatch on-link (bare URLs autolink), <details> blocks toggle via on-details + details-expanded, #123 refs linkify via issue-link-base."
|
||||
},
|
||||
{
|
||||
"name": "stepper",
|
||||
@@ -412,6 +412,10 @@
|
||||
"name": "min-width",
|
||||
"doc": "Width floor (plain number) without width's definite max: the element may grow past it but never shrink below. On split panes it bounds the divider drag."
|
||||
},
|
||||
{
|
||||
"name": "max-width",
|
||||
"doc": "Width ceiling (plain number) without width's definite min: the element still shrinks with a narrow parent. Use a growing child inside a centered row for a responsive content column."
|
||||
},
|
||||
{
|
||||
"name": "expanded",
|
||||
"doc": "Tree rows (role=\"treeitem\"): disclosure state (true/false or a {binding}). Omit on leaves; expanded rows collapse on Left, collapsed ones expand on Right, both through on-toggle - the model owns the state."
|
||||
@@ -428,6 +432,10 @@
|
||||
"name": "autofocus",
|
||||
"doc": "Focusable controls only: moves keyboard focus to the element when it mounts or when the value turns on (edge-triggered - holding it true never re-steals focus). The TEA way to focus an editor on create."
|
||||
},
|
||||
{
|
||||
"name": "submit-on-enter",
|
||||
"doc": "textarea only: true makes plain Enter dispatch on-submit while Shift+Enter inserts a newline; Cmd/Ctrl+Enter still submits. False or absent keeps the multiline default where Enter inserts and submission uses the primary chord."
|
||||
},
|
||||
{
|
||||
"name": "icon",
|
||||
"doc": "button, toggle-button, list-item, menu-item: vector icon drawn inline (buttons/toggle-buttons before the label, list/menu items as a leading slot): a built-in name (comptime-validated against canvas.icons.known_icon_names, e.g. save, plus, refresh-cw), an app-registered app:<name>, or one {binding} resolving to such a name. Icon-only buttons when the content is empty — add a label. One hit target, one enabled/disabled tint."
|
||||
@@ -565,6 +573,10 @@
|
||||
"name": "source",
|
||||
"doc": "markdown: one {binding} producing the markdown text (a []const u8 field or fn; arena fns work). Required."
|
||||
},
|
||||
{
|
||||
"name": "images",
|
||||
"doc": "markdown: one {binding} producing []const canvas.markdown.ResolvedImage (arena fns work). Each mapping pairs a leading image source with an image id already loaded and registered through fx.loadImage plus its decoded dimensions; views never perform image I/O."
|
||||
},
|
||||
{
|
||||
"name": "on-link",
|
||||
"doc": "markdown: bare Msg tag dispatched on link press; its payload is the URL ([]const u8 variant)."
|
||||
@@ -599,6 +611,14 @@
|
||||
"name": "line-numbers",
|
||||
"doc": "code: opt into muted logical line numbers. Off by default; a wrapped logical line stays paired with its number."
|
||||
},
|
||||
{
|
||||
"name": "added-lines",
|
||||
"doc": "code: one-based comma/range spec (for example 5 or 5, 9-11), or one text {binding}; applies Geist's green full-line wash and renderer-owned + without changing copied source. Lines 1-128."
|
||||
},
|
||||
{
|
||||
"name": "removed-lines",
|
||||
"doc": "code: one-based comma/range spec (for example 2-4), or one text {binding}; applies Geist's red full-line wash and renderer-owned - without changing copied source. Lines 1-128."
|
||||
},
|
||||
{
|
||||
"name": "wrap",
|
||||
"doc": "code: true by default. false preserves logical lines and puts the highlighted content in one horizontal scroll region."
|
||||
@@ -1196,6 +1216,10 @@
|
||||
"width": 1120,
|
||||
"height": 600
|
||||
},
|
||||
"code-diff": {
|
||||
"width": 1120,
|
||||
"height": 440
|
||||
},
|
||||
"markdown": {
|
||||
"width": 1120,
|
||||
"height": 880
|
||||
@@ -1551,4 +1575,4 @@
|
||||
"path": "src/components/timeline_item.zig"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const unprefixedNavSections: NavSection[] = [
|
||||
items: [
|
||||
{ name: "App Model", href: "/app-model" },
|
||||
{ name: "TypeScript Cores", href: "/typescript" },
|
||||
{ name: "TypeScript Services", href: "/typescript/services" },
|
||||
{ name: "Where Packages Go", href: "/typescript/packages" },
|
||||
{ name: "Native UI", href: "/native-ui" },
|
||||
{ name: "Dynamic Images", href: "/dynamic-images" },
|
||||
@@ -37,6 +38,14 @@ const unprefixedNavSections: NavSection[] = [
|
||||
{ name: "Building Components", href: "/building-components" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Data",
|
||||
items: [
|
||||
{ name: "Model Persistence", href: "/persistence" },
|
||||
{ name: "Record Store", href: "/record-store" },
|
||||
{ name: "Relational SQLite", href: "/sqlite" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// One entry per built-in component page, generated from the shared
|
||||
// components-pages inventory (previews regenerate via
|
||||
|
||||
@@ -9,9 +9,13 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"quick-start": "Quick Start",
|
||||
"app-model": "App Model",
|
||||
typescript: "TypeScript Cores",
|
||||
"typescript/services": "TypeScript Services",
|
||||
"typescript/packages": "Where Packages Go",
|
||||
"native-ui": "Native UI",
|
||||
"dynamic-images": "Dynamic Images",
|
||||
persistence: "Model Persistence",
|
||||
"record-store": "Record Store",
|
||||
sqlite: "Relational SQLite",
|
||||
terminal: "Terminal",
|
||||
state: "State & Data Flow",
|
||||
theming: "Theming",
|
||||
|
||||
+5
-5
@@ -139,10 +139,10 @@ Add a case by creating `cases/<name>/eval.json` (see `src/types.ts` for the sche
|
||||
|
||||
Cases with `"frontend": "ts-core"` measure the other authoring surface: the app core written in the TypeScript subset and compiled by `packages/core`. The scaffold is not an app — it is `src/core.ts` (the case's `starter/` overlay when it ships one, else a minimal counter core), a README with the check loop, and the `ts-core` skill delivered via `native skills get ts-core`. Two graders replace build/markup/snapshot:
|
||||
|
||||
- `ts_transpile` — the transpiler must exit clean on `src/core.ts`: tsc-semantics typecheck, every subset rule (NS1001-NS1050), Zig emission. Failing diagnostics stay in the result as the violation evidence.
|
||||
- `ts_harness` — behavioral grading: transpile the core, assemble a scratch dir with the emitted `core.zig`, the rt kernel, and the case's `harness.zig`, then `zig test harness.zig`. The harness drives the real dispatch cycle (`update` → `commitModelRoot` → `frameReset`) and asserts the prompt's requirements, so the case prompt pins the Model/Msg/export contract exactly (an API spec, not a solution).
|
||||
- `ts_transpile` — the frontend check must exit clean on `src/core.ts`: tsc-semantics typecheck, every subset rule (NS1001-NS1050) — no Zig emission; the literal name is the `eval.json` check key, the semantics are `ts_check`. Failing diagnostics stay in the result as the violation evidence.
|
||||
- `ts_harness` — behavioral grading: compile the core through the external core compiler and drive the compiled archive through a generated mirror — the scratch dir carries the mirror module, the archive, and the case's `harness.zig`, then `zig test harness.zig`. The harness drives the real dispatch cycle (`update` → `commitModelRoot` → `frameReset`) and asserts the prompt's requirements, so the case prompt pins the Model/Msg/export contract exactly (an API spec, not a solution).
|
||||
|
||||
Because the grading harness compiles against the agent's code, ts-core prompts pin names; behavior stays requirements-only. The transpiler compiles pure `update(model, msg): Model` cores and effectful `Model | [Model, Cmd<Msg>]` pair-returns (the Cmd surface); the wave-2 dual-track cases below are the effects coverage.
|
||||
Because the grading harness compiles against the agent's code, ts-core prompts pin names; behavior stays requirements-only. The compiler handles pure `update(model, msg): Model` cores and effectful `Model | [Model, Cmd<Msg>]` pair-returns (the Cmd surface); the wave-2 dual-track cases below are the effects coverage.
|
||||
|
||||
The ts-core cases:
|
||||
|
||||
@@ -155,7 +155,7 @@ The ts-core cases:
|
||||
|
||||
Wave 1's four ts-* cases measured subset compliance on toy katas and compared against Zig numbers gathered **before** the `zig` 0.16-idioms skill existed. Wave 2 replaces that comparison with an honest, contemporaneous one: six realistic asks, each ONE language-blind spec (`"frontend": "app-dual"`) that runs on **both authoring tracks** — `<case>@ts` scaffolds a full TypeScript app (`native init --frontend native --template ts-core`), `<case>@zig` the Zig app template (`--template zig-core`). Identical prompt, identical shared checks, one behavioral spec asserted by two thin per-track harnesses; `--track ts|zig` selects a lane, the default runs both.
|
||||
|
||||
Grading per track: the shared checks (`native test -Dplatform=null`, markup check, view greps, the judge with a case rubric) plus the track's behavioral harness. On the ts track, `ts_harness` transpiles `src/core.ts` (its whole import graph) and `zig test`s the case's `harness-ts.zig` against the emitted core, the rt kernel, and `harness-lib/cmdview.zig` — a decoder over the Cmd/Sub wire format, so harnesses assert effects semantically ("one GET to the pinned URL", "the delay re-armed on the same key"). On the zig track, `zig_harness` injects the case's `harness-zig.zig` into the workspace as `src/eval_behavior_spec.zig` (a test import appended to `src/main.zig`, restored afterward) and runs `native test`, so it compiles against the agent's real Model/Msg/update and drives the SDK's deterministic **fake effects executor** (`fx.executor = .fake`, `pendingSpawnAt`/`feedLine`/`feedExit`/`fireTimer`/`feedResponse`/`feedFileResult`).
|
||||
Grading per track: the shared checks (`native test -Dplatform=null`, markup check, view greps, the judge with a case rubric) plus the track's behavioral harness. On the ts track, `ts_harness` compiles `src/core.ts` (its whole import graph) through the external core compiler and `zig test`s the case's `harness-ts.zig` against the compiled core's generated mirror and `harness-lib/cmdview.zig` — a decoder over the Cmd/Sub wire format, so harnesses assert effects semantically ("one GET to the pinned URL", "the delay re-armed on the same key"). On the zig track, `zig_harness` injects the case's `harness-zig.zig` into the workspace as `src/eval_behavior_spec.zig` (a test import appended to `src/main.zig`, restored afterward) and runs `native test`, so it compiles against the agent's real Model/Msg/update and drives the SDK's deterministic **fake effects executor** (`fx.executor = .fake`, `pendingSpawnAt`/`feedLine`/`feedExit`/`fireTimer`/`feedResponse`/`feedFileResult`).
|
||||
|
||||
The cases — every prompt reads like a real user ask, and every effect result is fed by the harness (no network, no processes, no clocks during grading):
|
||||
|
||||
@@ -172,7 +172,7 @@ Starters (`starter-ts/`, `starter-zig/`) overlay the scaffold for the feature-ad
|
||||
|
||||
### Authoring metrics
|
||||
|
||||
`pnpm metrics results/<stamp> [...]` post-processes finished runs' transcripts into the agent-authoring metrics the checks cannot see, per case and per track — **ts** (the ts-core cases and the `@ts` side of dual cases) vs **zig** (the pre-existing native cases and the `@zig` side): **first-pass compliance** (did the agent's first compliance check after touching sources pass — the transpiler run, `native check`, `native test`, or `native build` on the ts track; `native test` / `native build` / `native check` / `native markup check` on the zig track), **retries-to-green** (failing compliance runs before the first green), **teaching-error encounters** (failing compliance runs that carried a teaching diagnostic — the "did the diagnostics work" round-trip count wave 2 compares across tracks), **violation taxonomy** (NS/TS rule IDs on the ts track; zig error lines on the zig track, with `no member named 'X'` bucketed by member so the 0.16-idiom class is visible) raw and per 1k generated LOC (lines written through Write/Edit to source files, `.native` markup included), and **task success** (the run's own pass verdict). Harness friction — permission-refused commands, errored compounds with no diagnostic in the output — is dropped from the event stream so it never masquerades as an authoring failure. It writes `authoring-metrics.json` next to each `summary.json`.
|
||||
`pnpm metrics results/<stamp> [...]` post-processes finished runs' transcripts into the agent-authoring metrics the checks cannot see, per case and per track — **ts** (the ts-core cases and the `@ts` side of dual cases) vs **zig** (the pre-existing native cases and the `@zig` side): **first-pass compliance** (did the agent's first compliance check after touching sources pass — the frontend check, `native check`, `native test`, or `native build` on the ts track; `native test` / `native build` / `native check` / `native markup check` on the zig track), **retries-to-green** (failing compliance runs before the first green), **teaching-error encounters** (failing compliance runs that carried a teaching diagnostic — the "did the diagnostics work" round-trip count wave 2 compares across tracks), **violation taxonomy** (NS/TS rule IDs on the ts track; zig error lines on the zig track, with `no member named 'X'` bucketed by member so the 0.16-idiom class is visible) raw and per 1k generated LOC (lines written through Write/Edit to source files, `.native` markup included), and **task success** (the run's own pass verdict). Harness friction — permission-refused commands, errored compounds with no diagnostic in the output — is dropped from the event stream so it never masquerades as an authoring failure. It writes `authoring-metrics.json` next to each `summary.json`.
|
||||
|
||||
## CI
|
||||
|
||||
|
||||
+380
-22
@@ -1,5 +1,5 @@
|
||||
//! Decoder over the app-core Cmd/Sub wire format (rt.zig, cmd_format_version
|
||||
//! 3), shared by the ts-track behavioral harnesses. The graders copy this
|
||||
//! 4), shared by the ts-track behavioral harnesses. The graders copy this
|
||||
//! file next to each case's harness so assertions read decoded ops — "a
|
||||
//! fetch with key `feed` targeting this URL", "the delay re-armed" — instead
|
||||
//! of hand-built byte strings, which keeps harnesses lenient about the parts
|
||||
@@ -15,11 +15,22 @@ pub const Op = union(enum) {
|
||||
now: struct { msg_tag: u8 },
|
||||
host: Host,
|
||||
host_bytes: struct { name: []const u8, payload: []const u8 },
|
||||
request: struct { name: []const u8, key: []const u8, ok_tag: u8, err_tag: u8, payload: []const u8 },
|
||||
request: struct { name: []const u8, key: []const u8, ok_tag: u8, err_tag: u8, typed_service: bool, payload: []const u8 },
|
||||
service_stream_request: struct {
|
||||
channel_key: f64,
|
||||
event_tag: u8,
|
||||
max_pending: u8,
|
||||
name: []const u8,
|
||||
key: []const u8,
|
||||
ok_tag: u8,
|
||||
err_tag: u8,
|
||||
payload: []const u8,
|
||||
},
|
||||
cancel: struct { key: []const u8 },
|
||||
read_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8 },
|
||||
write_file: struct { key: []const u8, ok_tag: u8, err_tag: u8, path: []const u8, bytes: []const u8 },
|
||||
fetch: Fetch,
|
||||
fetch_stream: FetchStream,
|
||||
clip_write: struct { bytes: []const u8 },
|
||||
clip_read: struct { key: []const u8, ok_tag: u8, err_tag: u8 },
|
||||
delay: struct { key: []const u8, after_ms: f64, msg_tag: u8 },
|
||||
@@ -27,16 +38,26 @@ pub const Op = union(enum) {
|
||||
audio_play: struct { key: []const u8, event_tag: u8, path: []const u8, url: []const u8, cache_path: []const u8, expected_bytes: f64 },
|
||||
audio_ctl: struct { key: []const u8, verb: u8, value: f64 },
|
||||
window_show: struct { label: []const u8 },
|
||||
window_hide: struct { label: []const u8 },
|
||||
dock_presence: struct { visible: bool },
|
||||
store_set: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8, bytes: []const u8 },
|
||||
store_get: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8 },
|
||||
store_delete: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, store_key: []const u8 },
|
||||
store_scan: struct { key: []const u8, ok_tag: u8, err_tag: u8, scope: u32, prefix: []const u8, limit: u32, after: []const u8 },
|
||||
store_set_many: StoreSetMany,
|
||||
quit_app,
|
||||
image_load: struct { id: f64, event_tag: u8, path: []const u8, url: []const u8, cache_path: []const u8, expected_bytes: f64 },
|
||||
image_cancel: struct { id: f64 },
|
||||
image_unregister: struct { id: f64 },
|
||||
channel_open: struct { key: f64, event_tag: u8 },
|
||||
channel_open: struct { key: f64, event_tag: u8, max_pending: u8 },
|
||||
channel_close: struct { key: f64 },
|
||||
pty_spawn: PtySpawn,
|
||||
pty_write: struct { key: []const u8, bytes: []const u8 },
|
||||
pty_resize: struct { key: []const u8, cols: f64, rows: f64 },
|
||||
pty_kill: struct { key: []const u8 },
|
||||
show_notification: struct { title: []const u8, subtitle: []const u8, body: []const u8 },
|
||||
audio_capture_start: struct { key: f64, source: u8, sample_rate: u32, channels: u8, event_tag: u8 },
|
||||
audio_capture_stop: struct { key: f64 },
|
||||
|
||||
pub const Host = struct {
|
||||
name: []const u8,
|
||||
@@ -67,6 +88,21 @@ pub const Op = union(enum) {
|
||||
body: []const u8,
|
||||
};
|
||||
|
||||
pub const FetchStream = struct {
|
||||
key: []const u8,
|
||||
line_tag: u8,
|
||||
ok_tag: u8,
|
||||
err_tag: u8,
|
||||
method: u8,
|
||||
timeout_ms: u32,
|
||||
max_line_bytes: u32,
|
||||
url: []const u8,
|
||||
header_count: u8,
|
||||
/// Raw header block: per header [name_len u8][name][value_len u32 LE][value].
|
||||
header_bytes: []const u8,
|
||||
body: []const u8,
|
||||
};
|
||||
|
||||
pub const Spawn = struct {
|
||||
key: []const u8,
|
||||
/// 0xFF = no line routing (rt.spawn_no_line_tag).
|
||||
@@ -116,6 +152,26 @@ pub const Op = union(enum) {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const StoreSetMany = struct {
|
||||
key: []const u8,
|
||||
ok_tag: u8,
|
||||
err_tag: u8,
|
||||
scope: u32,
|
||||
count: u32,
|
||||
/// Raw entries: `[key_len u32][key][value_len u32][value]`.
|
||||
entry_bytes: []const u8,
|
||||
|
||||
pub fn entry(self: StoreSetMany, index: usize) struct { key: []const u8, bytes: []const u8 } {
|
||||
var off: usize = 0;
|
||||
var i: usize = 0;
|
||||
while (true) : (i += 1) {
|
||||
const key = longBytes(self.entry_bytes, &off);
|
||||
const bytes = longBytes(self.entry_bytes, &off);
|
||||
if (i == index) return .{ .key = key, .bytes = bytes };
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
pub const CmdIter = struct {
|
||||
@@ -157,9 +213,10 @@ pub const CmdIter = struct {
|
||||
const key = shortBytes(b, &off);
|
||||
const ok = b[off];
|
||||
const err = b[off + 1];
|
||||
off += 2;
|
||||
const typed_service = b[off + 2] != 0;
|
||||
off += 3;
|
||||
const payload = longBytes(b, &off);
|
||||
break :blk .{ .request = .{ .name = name, .key = key, .ok_tag = ok, .err_tag = err, .payload = payload } };
|
||||
break :blk .{ .request = .{ .name = name, .key = key, .ok_tag = ok, .err_tag = err, .typed_service = typed_service, .payload = payload } };
|
||||
},
|
||||
0x06 => blk: {
|
||||
const key = shortBytes(b, &off);
|
||||
@@ -283,16 +340,15 @@ pub const CmdIter = struct {
|
||||
off += 8;
|
||||
break :blk .{ .image_unregister = .{ .id = id } };
|
||||
},
|
||||
// channel_open [op][key f64 LE][event_tag u8] — the bytes
|
||||
// channel_open [op][key f64 LE][event_tag u8][max_pending u8] — the bytes
|
||||
// rt.zig's cmdChannelOpen builds (ts_core_host.zig, 0x15).
|
||||
// No max_pending rides the wire: the host opens with the
|
||||
// engine default.
|
||||
0x15 => blk: {
|
||||
const key: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little));
|
||||
off += 8;
|
||||
const event_tag = b[off];
|
||||
off += 1;
|
||||
break :blk .{ .channel_open = .{ .key = key, .event_tag = event_tag } };
|
||||
const max_pending = b[off + 1];
|
||||
off += 2;
|
||||
break :blk .{ .channel_open = .{ .key = key, .event_tag = event_tag, .max_pending = max_pending } };
|
||||
},
|
||||
// channel_close [op][key f64 LE] (ts_core_host.zig, 0x16).
|
||||
0x16 => blk: {
|
||||
@@ -344,6 +400,152 @@ pub const CmdIter = struct {
|
||||
const key = shortBytes(b, &off);
|
||||
break :blk .{ .pty_kill = .{ .key = key } };
|
||||
},
|
||||
// show_notification [op 0x1D][title/subtitle/body as u32-length
|
||||
// bytes] (ts_core_host.zig, 0x1D).
|
||||
0x1D => blk: {
|
||||
const title = longBytes(b, &off);
|
||||
const subtitle = longBytes(b, &off);
|
||||
const body = longBytes(b, &off);
|
||||
break :blk .{ .show_notification = .{ .title = title, .subtitle = subtitle, .body = body } };
|
||||
},
|
||||
// audio_capture_start [op 0x1E][key f64 LE][source u8]
|
||||
// [sample_rate u32 LE][channels u8][event_tag u8].
|
||||
0x1E => blk: {
|
||||
const key: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little));
|
||||
off += 8;
|
||||
const source = b[off];
|
||||
off += 1;
|
||||
const sample_rate = std.mem.readInt(u32, b[off..][0..4], .little);
|
||||
off += 4;
|
||||
const channels = b[off];
|
||||
const event_tag = b[off + 1];
|
||||
off += 2;
|
||||
break :blk .{ .audio_capture_start = .{
|
||||
.key = key,
|
||||
.source = source,
|
||||
.sample_rate = sample_rate,
|
||||
.channels = channels,
|
||||
.event_tag = event_tag,
|
||||
} };
|
||||
},
|
||||
// audio_capture_stop [op 0x1F][key f64 LE].
|
||||
0x1F => blk: {
|
||||
const key: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little));
|
||||
off += 8;
|
||||
break :blk .{ .audio_capture_stop = .{ .key = key } };
|
||||
},
|
||||
// fetch_stream [op 0x20][key][line/ok/err tags][method]
|
||||
// [timeout u32 LE][max line u32 LE][url][headers][body]
|
||||
// (ts_core_host.zig, 0x20).
|
||||
0x20 => blk: {
|
||||
const key = shortBytes(b, &off);
|
||||
const line_tag = b[off];
|
||||
const ok_tag = b[off + 1];
|
||||
const err_tag = b[off + 2];
|
||||
const method = b[off + 3];
|
||||
off += 4;
|
||||
const timeout = std.mem.readInt(u32, b[off..][0..4], .little);
|
||||
off += 4;
|
||||
const max_line_bytes = std.mem.readInt(u32, b[off..][0..4], .little);
|
||||
off += 4;
|
||||
const url = longBytes(b, &off);
|
||||
const header_count = b[off];
|
||||
off += 1;
|
||||
const headers_start = off;
|
||||
var h: usize = 0;
|
||||
while (h < header_count) : (h += 1) {
|
||||
_ = shortBytes(b, &off);
|
||||
_ = longBytes(b, &off);
|
||||
}
|
||||
const header_bytes = b[headers_start..off];
|
||||
const body = longBytes(b, &off);
|
||||
break :blk .{ .fetch_stream = .{
|
||||
.key = key,
|
||||
.line_tag = line_tag,
|
||||
.ok_tag = ok_tag,
|
||||
.err_tag = err_tag,
|
||||
.method = method,
|
||||
.timeout_ms = timeout,
|
||||
.max_line_bytes = max_line_bytes,
|
||||
.url = url,
|
||||
.header_count = header_count,
|
||||
.header_bytes = header_bytes,
|
||||
.body = body,
|
||||
} };
|
||||
},
|
||||
// window_hide [op 0x21][label_len u8][label].
|
||||
0x21 => blk: {
|
||||
const label = shortBytes(b, &off);
|
||||
break :blk .{ .window_hide = .{ .label = label } };
|
||||
},
|
||||
// dock_presence [op 0x22][visible u8].
|
||||
0x22 => blk: {
|
||||
const visible = b[off] != 0;
|
||||
off += 1;
|
||||
break :blk .{ .dock_presence = .{ .visible = visible } };
|
||||
},
|
||||
// Atomic typed streaming-service admission.
|
||||
0x28 => blk: {
|
||||
const channel_key: f64 = @bitCast(std.mem.readInt(u64, b[off..][0..8], .little));
|
||||
off += 8;
|
||||
const event_tag = b[off];
|
||||
const max_pending = b[off + 1];
|
||||
off += 2;
|
||||
const name = shortBytes(b, &off);
|
||||
const key = shortBytes(b, &off);
|
||||
const ok_tag = b[off];
|
||||
const err_tag = b[off + 1];
|
||||
off += 2;
|
||||
const payload = longBytes(b, &off);
|
||||
break :blk .{ .service_stream_request = .{
|
||||
.channel_key = channel_key,
|
||||
.event_tag = event_tag,
|
||||
.max_pending = max_pending,
|
||||
.name = name,
|
||||
.key = key,
|
||||
.ok_tag = ok_tag,
|
||||
.err_tag = err_tag,
|
||||
.payload = payload,
|
||||
} };
|
||||
},
|
||||
0x23 => blk: {
|
||||
const head = routedHead(b, &off);
|
||||
const scope = readU32(b, &off);
|
||||
const store_key = longBytes(b, &off);
|
||||
const bytes = longBytes(b, &off);
|
||||
break :blk .{ .store_set = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key, .bytes = bytes } };
|
||||
},
|
||||
0x24 => blk: {
|
||||
const head = routedHead(b, &off);
|
||||
const scope = readU32(b, &off);
|
||||
const store_key = longBytes(b, &off);
|
||||
break :blk .{ .store_get = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key } };
|
||||
},
|
||||
0x25 => blk: {
|
||||
const head = routedHead(b, &off);
|
||||
const scope = readU32(b, &off);
|
||||
const store_key = longBytes(b, &off);
|
||||
break :blk .{ .store_delete = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .store_key = store_key } };
|
||||
},
|
||||
0x26 => blk: {
|
||||
const head = routedHead(b, &off);
|
||||
const scope = readU32(b, &off);
|
||||
const prefix = longBytes(b, &off);
|
||||
const limit = readU32(b, &off);
|
||||
const after = longBytes(b, &off);
|
||||
break :blk .{ .store_scan = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .prefix = prefix, .limit = limit, .after = after } };
|
||||
},
|
||||
0x27 => blk: {
|
||||
const head = routedHead(b, &off);
|
||||
const scope = readU32(b, &off);
|
||||
const count = readU32(b, &off);
|
||||
const entries_start = off;
|
||||
for (0..count) |_| {
|
||||
_ = longBytes(b, &off);
|
||||
_ = longBytes(b, &off);
|
||||
}
|
||||
break :blk .{ .store_set_many = .{ .key = head.key, .ok_tag = head.ok, .err_tag = head.err, .scope = scope, .count = count, .entry_bytes = b[entries_start..off] } };
|
||||
},
|
||||
else => std.debug.panic("cmdview: unknown op byte 0x{X:0>2} at offset {d}", .{ op, self.off }),
|
||||
};
|
||||
self.off = off;
|
||||
@@ -405,6 +607,12 @@ fn longBytes(b: []const u8, off: *usize) []const u8 {
|
||||
return out;
|
||||
}
|
||||
|
||||
fn readU32(b: []const u8, off: *usize) u32 {
|
||||
const value = std.mem.readInt(u32, b[off.*..][0..4], .little);
|
||||
off.* += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
/// First decoded op of the given kind in a cmd buffer, or null.
|
||||
@@ -464,6 +672,45 @@ test "window_show and quit_app decode, alone and inside a batch" {
|
||||
try std.testing.expectEqual(@as(?Op, null), iter.next());
|
||||
}
|
||||
|
||||
test "window_hide and dock_presence decode, alone and inside a batch" {
|
||||
const hidden = findOp(&.{ 0x21, 4, 'm', 'a', 'i', 'n' }, .window_hide) orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqualStrings("main", hidden.label);
|
||||
|
||||
const dock = findOp(&.{ 0x22, 0 }, .dock_presence) orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expect(!dock.visible);
|
||||
|
||||
const batch = [_]u8{ 0x21, 3, 'h', 'u', 'd', 0x22, 1, 0x02, 9 };
|
||||
var iter = CmdIter.init(&batch);
|
||||
try std.testing.expectEqualStrings("hud", (iter.next() orelse return error.TestUnexpectedResult).window_hide.label);
|
||||
try std.testing.expect((iter.next() orelse return error.TestUnexpectedResult).dock_presence.visible);
|
||||
try std.testing.expectEqual(@as(u8, 9), (iter.next() orelse return error.TestUnexpectedResult).now.msg_tag);
|
||||
try std.testing.expectEqual(@as(?Op, null), iter.next());
|
||||
}
|
||||
|
||||
test "record store command records decode and advance exactly" {
|
||||
const batch = [_]u8{
|
||||
0x23, 1, 'r', 2, 3, 0, 0, 0, 0, 1, 0, 0, 0, 'k', 1, 0, 0, 0, 'v',
|
||||
0x26, 0, 4, 5, 0, 0, 0, 0, 2, 0, 0, 0, 'p', '/', 7, 0, 0, 0, 1,
|
||||
0, 0, 0, 'a', 0x27, 1, 'b', 6, 7, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0,
|
||||
0, 0, 'x', 2, 0, 0, 0, 8, 9, 0x02, 10,
|
||||
};
|
||||
var iter = CmdIter.init(&batch);
|
||||
const set = (iter.next() orelse return error.TestUnexpectedResult).store_set;
|
||||
try std.testing.expectEqualStrings("r", set.key);
|
||||
try std.testing.expectEqualStrings("k", set.store_key);
|
||||
try std.testing.expectEqualSlices(u8, "v", set.bytes);
|
||||
const scan = (iter.next() orelse return error.TestUnexpectedResult).store_scan;
|
||||
try std.testing.expectEqualStrings("p/", scan.prefix);
|
||||
try std.testing.expectEqual(@as(u32, 7), scan.limit);
|
||||
try std.testing.expectEqualStrings("a", scan.after);
|
||||
const many = (iter.next() orelse return error.TestUnexpectedResult).store_set_many;
|
||||
try std.testing.expectEqual(@as(u32, 1), many.count);
|
||||
try std.testing.expectEqualStrings("x", many.entry(0).key);
|
||||
try std.testing.expectEqualSlices(u8, &.{ 8, 9 }, many.entry(0).bytes);
|
||||
try std.testing.expectEqual(@as(u8, 10), (iter.next() orelse return error.TestUnexpectedResult).now.msg_tag);
|
||||
try std.testing.expectEqual(@as(?Op, null), iter.next());
|
||||
}
|
||||
|
||||
test "the image records decode, alone and inside a batch" {
|
||||
// image_load: [op 0x12][id f64 LE][event_tag][path][url][cache]
|
||||
// [expected f64 LE] — the bytes rt.zig's cmdImageLoad pins (the same
|
||||
@@ -503,15 +750,16 @@ test "the image records decode, alone and inside a batch" {
|
||||
}
|
||||
|
||||
test "the channel records decode, alone and inside a batch" {
|
||||
// channel_open: [op 0x15][key f64 LE][event_tag u8] — the bytes
|
||||
// rt.zig's cmdChannelOpen pins (no max_pending on the wire).
|
||||
var open_bytes: [10]u8 = undefined;
|
||||
// channel_open: [op 0x15][key f64 LE][event_tag u8][max_pending u8].
|
||||
var open_bytes: [11]u8 = undefined;
|
||||
open_bytes[0] = 0x15;
|
||||
open_bytes[1..9].* = @bitCast(@as(f64, 41));
|
||||
open_bytes[9] = 5; // event_tag
|
||||
open_bytes[10] = 7; // max_pending
|
||||
const opened = findOp(&open_bytes, .channel_open) orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(@as(f64, 41), opened.key);
|
||||
try std.testing.expectEqual(@as(u8, 5), opened.event_tag);
|
||||
try std.testing.expectEqual(@as(u8, 7), opened.max_pending);
|
||||
|
||||
// channel_close: [op 0x16][key f64 LE].
|
||||
var close_bytes: [9]u8 = undefined;
|
||||
@@ -521,13 +769,13 @@ test "the channel records decode, alone and inside a batch" {
|
||||
try std.testing.expectEqual(@as(f64, 41), closed.key);
|
||||
|
||||
// A batch of open + close + a trailing now record: each record must
|
||||
// advance the iterator exactly its own length (ten bytes, then
|
||||
// advance the iterator exactly its own length (eleven bytes, then
|
||||
// nine) for the tail to decode.
|
||||
var batch: [21]u8 = undefined;
|
||||
batch[0..10].* = open_bytes;
|
||||
batch[10..19].* = close_bytes;
|
||||
batch[19] = 0x02;
|
||||
batch[20] = 7;
|
||||
var batch: [22]u8 = undefined;
|
||||
batch[0..11].* = open_bytes;
|
||||
batch[11..20].* = close_bytes;
|
||||
batch[20] = 0x02;
|
||||
batch[21] = 7;
|
||||
var iter = CmdIter.init(&batch);
|
||||
const first = iter.next() orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(@as(f64, 41), first.channel_open.key);
|
||||
@@ -538,6 +786,70 @@ test "the channel records decode, alone and inside a batch" {
|
||||
try std.testing.expectEqual(@as(?Op, null), iter.next());
|
||||
}
|
||||
|
||||
test "typed request metadata and atomic streaming service records decode" {
|
||||
const a = std.testing.allocator;
|
||||
var bytes: std.ArrayList(u8) = .empty;
|
||||
defer bytes.deinit(a);
|
||||
|
||||
try bytes.append(a, 0x05);
|
||||
try bytes.append(a, 5);
|
||||
try bytes.appendSlice(a, "parse");
|
||||
try bytes.append(a, 3);
|
||||
try bytes.appendSlice(a, "one");
|
||||
try bytes.appendSlice(a, &.{ 4, 5, 1, 2, 0, 0, 0, 'o', 'k' });
|
||||
const request = findOp(bytes.items, .request) orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqualStrings("parse", request.name);
|
||||
try std.testing.expect(request.typed_service);
|
||||
try std.testing.expectEqualStrings("ok", request.payload);
|
||||
|
||||
bytes.clearRetainingCapacity();
|
||||
try bytes.append(a, 0x28);
|
||||
try bytes.appendSlice(a, &@as([8]u8, @bitCast(@as(f64, 79))));
|
||||
try bytes.appendSlice(a, &.{ 6, 3 });
|
||||
try bytes.append(a, 6);
|
||||
try bytes.appendSlice(a, "stream");
|
||||
try bytes.append(a, 3);
|
||||
try bytes.appendSlice(a, "two");
|
||||
try bytes.appendSlice(a, &.{ 7, 8, 3, 0, 0, 0, 1, 2, 3 });
|
||||
const stream = findOp(bytes.items, .service_stream_request) orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(@as(f64, 79), stream.channel_key);
|
||||
try std.testing.expectEqual(@as(u8, 6), stream.event_tag);
|
||||
try std.testing.expectEqual(@as(u8, 3), stream.max_pending);
|
||||
try std.testing.expectEqualStrings("stream", stream.name);
|
||||
try std.testing.expectEqualStrings("two", stream.key);
|
||||
try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3 }, stream.payload);
|
||||
}
|
||||
|
||||
test "the audio capture records decode and advance a batch exactly" {
|
||||
var start: [16]u8 = undefined;
|
||||
start[0] = 0x1E;
|
||||
start[1..9].* = @bitCast(@as(f64, 91));
|
||||
start[9] = 1; // system
|
||||
std.mem.writeInt(u32, start[10..14], 24_000, .little);
|
||||
start[14] = 2;
|
||||
start[15] = 6;
|
||||
const opened = findOp(&start, .audio_capture_start) orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(@as(f64, 91), opened.key);
|
||||
try std.testing.expectEqual(@as(u8, 1), opened.source);
|
||||
try std.testing.expectEqual(@as(u32, 24_000), opened.sample_rate);
|
||||
try std.testing.expectEqual(@as(u8, 2), opened.channels);
|
||||
try std.testing.expectEqual(@as(u8, 6), opened.event_tag);
|
||||
|
||||
var stop: [9]u8 = undefined;
|
||||
stop[0] = 0x1F;
|
||||
stop[1..9].* = @bitCast(@as(f64, 91));
|
||||
var batch: [27]u8 = undefined;
|
||||
batch[0..16].* = start;
|
||||
batch[16..25].* = stop;
|
||||
batch[25..27].* = .{ 0x02, 7 };
|
||||
var iter = CmdIter.init(&batch);
|
||||
_ = iter.next() orelse return error.TestUnexpectedResult;
|
||||
const closed = iter.next() orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(@as(f64, 91), closed.audio_capture_stop.key);
|
||||
try std.testing.expectEqual(@as(u8, 7), (iter.next() orelse return error.TestUnexpectedResult).now.msg_tag);
|
||||
try std.testing.expectEqual(@as(?Op, null), iter.next());
|
||||
}
|
||||
|
||||
test "the pty records decode, alone and inside a batch" {
|
||||
const a = std.testing.allocator;
|
||||
|
||||
@@ -571,9 +883,10 @@ test "the pty records decode, alone and inside a batch" {
|
||||
try std.testing.expectEqualStrings("-l", spawned.arg(1));
|
||||
|
||||
// pty_write [0x1A][key][bytes u32-len], pty_resize [0x1B][key]
|
||||
// [cols f64 LE][rows f64 LE], pty_kill [0x1C][key], and a trailing
|
||||
// now record in one batch: each record must advance the iterator
|
||||
// exactly its own length for the tail to decode.
|
||||
// [cols f64 LE][rows f64 LE], pty_kill [0x1C][key], notification
|
||||
// [0x1D][title][subtitle][body], and a trailing now record in one batch:
|
||||
// each record must advance the iterator exactly its own length for the
|
||||
// tail to decode.
|
||||
var batch: std.ArrayList(u8) = .empty;
|
||||
defer batch.deinit(a);
|
||||
try batch.append(a, 0x1A);
|
||||
@@ -589,6 +902,13 @@ test "the pty records decode, alone and inside a batch" {
|
||||
try batch.append(a, 0x1C);
|
||||
try batch.append(a, 5);
|
||||
try batch.appendSlice(a, "shell");
|
||||
try batch.append(a, 0x1D);
|
||||
try batch.appendSlice(a, &.{ 5, 0, 0, 0 });
|
||||
try batch.appendSlice(a, "Ready");
|
||||
try batch.appendSlice(a, &.{ 3, 0, 0, 0 });
|
||||
try batch.appendSlice(a, "SDK");
|
||||
try batch.appendSlice(a, &.{ 4, 0, 0, 0 });
|
||||
try batch.appendSlice(a, "Done");
|
||||
try batch.appendSlice(a, &.{ 0x02, 7 });
|
||||
var iter = CmdIter.init(batch.items);
|
||||
const wrote = iter.next() orelse return error.TestUnexpectedResult;
|
||||
@@ -599,7 +919,45 @@ test "the pty records decode, alone and inside a batch" {
|
||||
try std.testing.expectEqual(@as(f64, 40), resized.pty_resize.rows);
|
||||
const killed = iter.next() orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqualStrings("shell", killed.pty_kill.key);
|
||||
const notification = iter.next() orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqualStrings("Ready", notification.show_notification.title);
|
||||
try std.testing.expectEqualStrings("SDK", notification.show_notification.subtitle);
|
||||
try std.testing.expectEqualStrings("Done", notification.show_notification.body);
|
||||
const tail = iter.next() orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqual(@as(u8, 7), tail.now.msg_tag);
|
||||
try std.testing.expectEqual(@as(?Op, null), iter.next());
|
||||
}
|
||||
|
||||
test "streaming fetch decodes its routes and limits" {
|
||||
const a = std.testing.allocator;
|
||||
var bytes: std.ArrayList(u8) = .empty;
|
||||
defer bytes.deinit(a);
|
||||
|
||||
try bytes.append(a, 0x20);
|
||||
try bytes.append(a, 4);
|
||||
try bytes.appendSlice(a, "chat");
|
||||
try bytes.appendSlice(a, &.{ 7, 8, 9, 1 });
|
||||
try bytes.appendSlice(a, &.{ 0x88, 0x13, 0, 0 }); // 5000 ms
|
||||
try bytes.appendSlice(a, &.{ 0, 0x20, 0, 0 }); // 8192 bytes
|
||||
try bytes.appendSlice(a, &.{ 15, 0, 0, 0 });
|
||||
try bytes.appendSlice(a, "https://ai.test");
|
||||
try bytes.append(a, 1);
|
||||
try bytes.append(a, 6);
|
||||
try bytes.appendSlice(a, "accept");
|
||||
try bytes.appendSlice(a, &.{ 17, 0, 0, 0 });
|
||||
try bytes.appendSlice(a, "text/event-stream");
|
||||
try bytes.appendSlice(a, &.{ 2, 0, 0, 0 });
|
||||
try bytes.appendSlice(a, "{}");
|
||||
|
||||
const stream = findOp(bytes.items, .fetch_stream) orelse return error.TestUnexpectedResult;
|
||||
try std.testing.expectEqualStrings("chat", stream.key);
|
||||
try std.testing.expectEqual(@as(u8, 7), stream.line_tag);
|
||||
try std.testing.expectEqual(@as(u8, 8), stream.ok_tag);
|
||||
try std.testing.expectEqual(@as(u8, 9), stream.err_tag);
|
||||
try std.testing.expectEqual(@as(u8, 1), stream.method);
|
||||
try std.testing.expectEqual(@as(u32, 5000), stream.timeout_ms);
|
||||
try std.testing.expectEqual(@as(u32, 8192), stream.max_line_bytes);
|
||||
try std.testing.expectEqualStrings("https://ai.test", stream.url);
|
||||
try std.testing.expectEqual(@as(u8, 1), stream.header_count);
|
||||
try std.testing.expectEqualStrings("{}", stream.body);
|
||||
}
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ const ALLOWED_TOOLS = [
|
||||
"Grep",
|
||||
"Bash(zig *)",
|
||||
"Bash(native *)",
|
||||
// ts-core cases: the agent's check loop is the @native-sdk/core transpiler run
|
||||
// ts-core cases: the agent's check loop is the @native-sdk/core frontend check
|
||||
// through node, and the subset runs under node for behavioral pokes.
|
||||
"Bash(node *)",
|
||||
"Bash(ls *)",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// the deterministic checks cannot see:
|
||||
//
|
||||
// - first-pass compliance: did the FIRST compliance check the agent ran after
|
||||
// first touching the sources pass? (ts-core: the @native-sdk/core transpiler run;
|
||||
// first touching the sources pass? (ts-core: the @native-sdk/core frontend check;
|
||||
// native: `native test` / `zig build test`.) Pre-edit runs don't count —
|
||||
// starters compile clean, so they would grade the scaffold, not the agent.
|
||||
// - retries-to-green: failing compliance runs before the first passing one
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// the agent itself ran and its exit status. So `firstGreenTurn` is the first
|
||||
// time the agent's OWN verification loop went green (the same command set the
|
||||
// graders' build_test/transpile checks run: `native test`, `zig build test`,
|
||||
// `native check`, the @native-sdk/core transpiler CLI, `markup check`) after
|
||||
// `native check`, the @native-sdk/core frontend CLI, `markup check`) after
|
||||
// the first source edit — a proxy for "the work was done", not a claim that
|
||||
// the full graded check set (file greps, behavioral harnesses, snapshots)
|
||||
// passed at that turn. It can read early (agent's check is weaker than the
|
||||
@@ -207,7 +207,7 @@ export function isSourceFile(path: string, track: MetricsTrack): boolean {
|
||||
|
||||
/**
|
||||
* The commands that constitute a compliance check. ts track: any invocation
|
||||
* of the @native-sdk/core transpiler CLI (typecheck + subset rules +
|
||||
* of the @native-sdk/core frontend CLI (typecheck + subset rules
|
||||
* emission), plus the app verbs an app-workspace loop runs (`native check`
|
||||
* runs the same checker; `native test`/`native build` compile the emitted
|
||||
* core and the markup bindings). zig track: the app build/test verbs the
|
||||
|
||||
+95
-23
@@ -9,7 +9,7 @@ import type { Workspace } from "./scaffold.ts";
|
||||
|
||||
export interface GradeContext {
|
||||
workspace: Workspace;
|
||||
/** SDK repo root (the @native-sdk/core transpiler and rt kernel live here). */
|
||||
/** SDK repo root (the @native-sdk/core frontend and the external core compiler live here). */
|
||||
repoRoot: string;
|
||||
/** The case directory (cases/<name>): ts_harness reads harness.zig from it. */
|
||||
caseDir: string;
|
||||
@@ -67,7 +67,7 @@ function checkDescription(check: CheckSpec): string {
|
||||
case "snapshot_grep":
|
||||
return `snapshot: ${check.description}`;
|
||||
case "ts_transpile":
|
||||
return `@native-sdk/core transpile ${check.entry ?? "src/core.ts"}`;
|
||||
return `@native-sdk/core check ${check.entry ?? "src/core.ts"}`;
|
||||
case "ts_harness":
|
||||
return `harness: ${check.description}`;
|
||||
case "zig_harness":
|
||||
@@ -108,24 +108,26 @@ async function runCheck(check: CheckSpec, context: GradeContext): Promise<Pendin
|
||||
}
|
||||
}
|
||||
|
||||
/** Path to the @native-sdk/core transpiler CLI inside the SDK repo. */
|
||||
function transpilerCli(repoRoot: string): string {
|
||||
/** Path to the @native-sdk/core frontend CLI inside the SDK repo. */
|
||||
function frontendCli(repoRoot: string): string {
|
||||
return join(repoRoot, "packages", "core", "src", "cli.ts");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compliance grading for ts-core cases: the core module must typecheck (tsc
|
||||
* semantics), pass every subset rule, and emit Zig. Failing diagnostics stay
|
||||
* in the detail — the NS rule IDs there are the violation taxonomy.
|
||||
* semantics) and pass every subset rule — the frontend's check-only pass,
|
||||
* the exact gate every build runs before the external core compiler takes
|
||||
* the graph. Failing diagnostics stay in the detail — the NS rule IDs there
|
||||
* are the violation taxonomy.
|
||||
*/
|
||||
async function tsTranspile(check: TsTranspileCheck, context: GradeContext): Promise<PendingResult> {
|
||||
const entry = check.entry ?? "src/core.ts";
|
||||
const description = `@native-sdk/core transpile ${entry}`;
|
||||
const description = `@native-sdk/core check ${entry}`;
|
||||
const entryPath = join(context.workspace.path, entry);
|
||||
if (!existsSync(entryPath)) {
|
||||
return { type: "ts_transpile", description, status: "fail", detail: `${entry} not found in the workspace` };
|
||||
}
|
||||
const result = await exec("node", [transpilerCli(context.repoRoot), entryPath, "-o", "/dev/null"], {
|
||||
const result = await exec("node", [frontendCli(context.repoRoot), entryPath], {
|
||||
cwd: context.workspace.path,
|
||||
timeoutMs: 2 * 60 * 1000,
|
||||
});
|
||||
@@ -139,10 +141,13 @@ async function tsTranspile(check: TsTranspileCheck, context: GradeContext): Prom
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavioral grading for ts-core cases: transpile the core, assemble a
|
||||
* scratch dir with the emitted core.zig, the rt kernel, and the case's
|
||||
* harness.zig, then `zig test harness.zig`. The harness drives the real
|
||||
* dispatch cycle and asserts the case's required behavior.
|
||||
* Behavioral grading for ts-core cases: compile the core through the
|
||||
* external core compiler (the same pipeline every build runs — frontend
|
||||
* check + contract, corewire facade/profile, staging, the pinned compile,
|
||||
* the mirror over the co-emitted sidecar), assemble a scratch dir with the
|
||||
* mirror as core.zig beside its shim runtime, the archive, and the case's
|
||||
* harness.zig, then `zig test harness.zig <archive> -lc`. The harness
|
||||
* drives the real dispatch cycle and asserts the case's required behavior.
|
||||
*/
|
||||
async function tsHarness(check: TsHarnessCheck, context: GradeContext): Promise<PendingResult> {
|
||||
const entry = check.entry ?? "src/core.ts";
|
||||
@@ -159,23 +164,90 @@ async function tsHarness(check: TsHarnessCheck, context: GradeContext): Promise<
|
||||
const scratch = join(context.workspace.path, ".harness");
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
mkdirSync(scratch, { recursive: true });
|
||||
const transpile = await exec(
|
||||
const core = join(context.repoRoot, "packages", "core");
|
||||
|
||||
// 1. Frontend check + the contract sidecar.
|
||||
const contract = join(scratch, "core.contract.json");
|
||||
const checkRun = await exec(
|
||||
"node",
|
||||
[transpilerCli(context.repoRoot), entryPath, "-o", join(scratch, "core.zig")],
|
||||
[frontendCli(context.repoRoot), entryPath, "--contract", contract, "--contract-entry", entry.split("\\").join("/")],
|
||||
{ cwd: context.workspace.path, timeoutMs: 2 * 60 * 1000 },
|
||||
);
|
||||
if (transpile.code !== 0) {
|
||||
return {
|
||||
type: "ts_harness",
|
||||
description,
|
||||
status: "fail",
|
||||
detail: `transpile failed:\n${tailLines(transpile)}`,
|
||||
};
|
||||
if (checkRun.code !== 0) {
|
||||
return { type: "ts_harness", description, status: "fail", detail: `frontend check failed:\n${tailLines(checkRun)}` };
|
||||
}
|
||||
copyFileSync(join(context.repoRoot, "packages", "core", "rt", "rt.zig"), join(scratch, "rt.zig"));
|
||||
|
||||
// 2. corewire, compiled once into the scratch (facade/profile + mirror).
|
||||
const corewire = join(scratch, "corewire");
|
||||
const buildCorewire = await exec(
|
||||
"zig",
|
||||
["build-exe", join(context.repoRoot, "tools", "corewire", "main.zig"), `-femit-bin=${corewire}`],
|
||||
{ cwd: scratch, timeoutMs: 5 * 60 * 1000 },
|
||||
);
|
||||
if (buildCorewire.code !== 0) {
|
||||
return { type: "ts_harness", description, status: "fail", detail: `corewire build failed:\n${tailLines(buildCorewire)}` };
|
||||
}
|
||||
const facade = join(scratch, "core_facade.ts");
|
||||
const profile = join(scratch, "core_profile.json");
|
||||
const project = await exec(corewire, ["--sidecar", contract, "--facade", facade, "--profile", profile], {
|
||||
cwd: scratch,
|
||||
timeoutMs: 60 * 1000,
|
||||
});
|
||||
if (project.code !== 0) {
|
||||
return { type: "ts_harness", description, status: "fail", detail: `corewire projection failed:\n${tailLines(project)}` };
|
||||
}
|
||||
|
||||
// 3. Stage and compile through the pinned external toolchain.
|
||||
const stage = join(scratch, "stage");
|
||||
const staged = await exec(
|
||||
"node",
|
||||
[
|
||||
join(core, "scripts", "stage_external_core.mjs"),
|
||||
"--src", join(context.workspace.path, "src"),
|
||||
"--sdk", join(core, "sdk"),
|
||||
"--static", join(core, "compile-surface", "core.ts"),
|
||||
"--facade", facade,
|
||||
"--profile", profile,
|
||||
"--out", stage,
|
||||
],
|
||||
{ cwd: scratch, timeoutMs: 60 * 1000 },
|
||||
);
|
||||
if (staged.code !== 0) {
|
||||
return { type: "ts_harness", description, status: "fail", detail: `compile staging failed:\n${tailLines(staged)}` };
|
||||
}
|
||||
const archive = join(scratch, "libeval_core.a");
|
||||
const compiledSidecar = join(scratch, "compiled.contract.json");
|
||||
const compile = await exec(
|
||||
"node",
|
||||
[
|
||||
join(core, "scripts", "run_external_core_compiler.mjs"),
|
||||
"--stage", stage,
|
||||
"--name", "eval_core",
|
||||
"--manifest", join(core, "package.json"),
|
||||
"--out-archive", archive,
|
||||
"--out-sidecar", compiledSidecar,
|
||||
"--compiler-js", join(core, "node_modules", "scriptc", "dist", "main.js"),
|
||||
],
|
||||
{ cwd: scratch, timeoutMs: 10 * 60 * 1000 },
|
||||
);
|
||||
if (compile.code !== 0) {
|
||||
return { type: "ts_harness", description, status: "fail", detail: `external core compile failed:\n${tailLines(compile)}` };
|
||||
}
|
||||
|
||||
// 4. The mirror over the archive's OWN co-emitted contract, staged as
|
||||
// the harness's core.zig beside its shim runtime.
|
||||
const mirror = await exec(corewire, ["--sidecar", compiledSidecar, "--out", join(scratch, "core.zig")], {
|
||||
cwd: scratch,
|
||||
timeoutMs: 60 * 1000,
|
||||
});
|
||||
if (mirror.code !== 0) {
|
||||
return { type: "ts_harness", description, status: "fail", detail: `mirror generation failed:\n${tailLines(mirror)}` };
|
||||
}
|
||||
copyFileSync(join(context.repoRoot, "tools", "corewire", "shim_rt.zig"), join(scratch, "shim_rt.zig"));
|
||||
copyFileSync(join(context.repoRoot, "tools", "corewire", "core_abi.zig"), join(scratch, "core_abi.zig"));
|
||||
copyFileSync(harnessPath, join(scratch, "harness.zig"));
|
||||
copyHarnessLib(context, scratch);
|
||||
const test = await exec("zig", ["test", "harness.zig"], {
|
||||
const test = await exec("zig", ["test", "harness.zig", archive, "-lc"], {
|
||||
cwd: scratch,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
+16
-52
@@ -145,7 +145,7 @@ async function scaffoldAppWorkspace(
|
||||
* core), a README documenting the check loop, and the ts-core skill
|
||||
* delivered along the documented user path (`native skills get ts-core`).
|
||||
* No app scaffold: the core module is the whole deliverable, graded through
|
||||
* the @native-sdk/core transpiler and the case's zig-test harness.
|
||||
* the @native-sdk/core frontend and the case's zig-test harness.
|
||||
*/
|
||||
async function scaffoldTsCoreWorkspace(
|
||||
repoRoot: string,
|
||||
@@ -202,8 +202,7 @@ export function update(model: Model, msg: Msg): Model {
|
||||
`;
|
||||
|
||||
function tsCoreReadme(repoRoot: string): string {
|
||||
const transpiler = join(repoRoot, "packages", "core", "src", "cli.ts");
|
||||
const rt = join(repoRoot, "packages", "core", "rt", "rt.zig");
|
||||
const frontend = join(repoRoot, "packages", "core", "src", "cli.ts");
|
||||
return `# App-core workspace
|
||||
|
||||
This workspace holds one deliverable: \`src/core.ts\`, an app core written in the
|
||||
@@ -212,24 +211,15 @@ app-core TypeScript subset. The authoring guide is
|
||||
|
||||
## Check loop
|
||||
|
||||
Transpile after every meaningful edit; the diagnostics teach the rule, the fix,
|
||||
Check after every meaningful edit; the diagnostics teach the rule, the fix,
|
||||
and the reason:
|
||||
|
||||
\`\`\`sh
|
||||
node ${transpiler} src/core.ts -o /tmp/core.zig
|
||||
node ${frontend} src/core.ts
|
||||
\`\`\`
|
||||
|
||||
Exit 0 means the module typechecks, passes the subset checker, and emits Zig.
|
||||
|
||||
To sanity-check behavior natively, build the emitted core against the runtime
|
||||
kernel with a scratch test file that imports both:
|
||||
|
||||
\`\`\`sh
|
||||
mkdir -p .check && cp ${rt} .check/rt.zig
|
||||
node ${transpiler} src/core.ts -o .check/core.zig
|
||||
# write .check/smoke.zig with zig tests importing core.zig, then:
|
||||
cd .check && zig test smoke.zig
|
||||
\`\`\`
|
||||
Exit 0 means the module typechecks and passes the subset checker — the exact
|
||||
gate every build runs before the external core compiler takes the graph.
|
||||
|
||||
The subset is erasable TypeScript, so node can also import \`src/core.ts\`
|
||||
directly for quick behavioral pokes — semantics match the native build.
|
||||
@@ -260,50 +250,24 @@ export async function prewarmWorkspace(
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm a ts-core workspace: transpile the starter core once (proves the
|
||||
* scaffold compiles) and `zig test` a trivial harness against it so the zig
|
||||
* std/test-runner graph is cached before the agent's own check loops and the
|
||||
* ts_harness grader hit it.
|
||||
* Pre-warm a ts-core workspace: run the frontend check over the starter
|
||||
* core once — proves the scaffold is subset-clean before spending model
|
||||
* tokens (the ts_harness grader compiles through the external toolchain
|
||||
* itself and needs no warm zig graph here).
|
||||
*/
|
||||
export async function prewarmTsCoreWorkspace(
|
||||
repoRoot: string,
|
||||
workspace: Workspace,
|
||||
log: (line: string) => void,
|
||||
): Promise<void> {
|
||||
log("[prewarm] transpile starter + zig test smoke...");
|
||||
const scratch = join(workspace.path, ".prewarm");
|
||||
mkdirSync(scratch, { recursive: true });
|
||||
const transpile = await exec(
|
||||
log("[prewarm] frontend check over the starter core...");
|
||||
const check = await exec(
|
||||
"node",
|
||||
[
|
||||
join(repoRoot, "packages", "core", "src", "cli.ts"),
|
||||
join(workspace.path, "src", "core.ts"),
|
||||
"-o",
|
||||
join(scratch, "core.zig"),
|
||||
],
|
||||
[join(repoRoot, "packages", "core", "src", "cli.ts"), join(workspace.path, "src", "core.ts")],
|
||||
{ cwd: workspace.path, timeoutMs: 2 * 60 * 1000 },
|
||||
);
|
||||
if (transpile.code !== 0) {
|
||||
throw new Error(`pre-warm transpile failed — starter core is broken:\n${tailLines(transpile)}`);
|
||||
if (check.code !== 0) {
|
||||
throw new Error(`pre-warm check failed — starter core is broken:\n${tailLines(check)}`);
|
||||
}
|
||||
cpSync(join(repoRoot, "packages", "core", "rt", "rt.zig"), join(scratch, "rt.zig"));
|
||||
writeFileSync(
|
||||
join(scratch, "smoke.zig"),
|
||||
`const core = @import("core.zig");
|
||||
test "starter core initializes" {
|
||||
core.rt.resetAll();
|
||||
_ = core.commitModelRoot(core.initialModel());
|
||||
core.rt.frameReset();
|
||||
}
|
||||
`,
|
||||
);
|
||||
const smoke = await exec("zig", ["test", "smoke.zig"], {
|
||||
cwd: scratch,
|
||||
timeoutMs: 10 * 60 * 1000,
|
||||
});
|
||||
if (smoke.code !== 0) {
|
||||
throw new Error(`pre-warm zig test failed — starter core is broken:\n${tailLines(smoke)}`);
|
||||
}
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
log(`[prewarm] done in ${((transpile.durationMs + smoke.durationMs) / 1000).toFixed(0)}s`);
|
||||
log(`[prewarm] done in ${(check.durationMs / 1000).toFixed(0)}s`);
|
||||
}
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ export interface EvalCase {
|
||||
* Workspace shape. "native" scaffolds with `native init --frontend native`
|
||||
* (the Zig-core app template); "ts-core" scaffolds a core-only TypeScript
|
||||
* workspace (src/core.ts starter, README, the ts-core skill) graded
|
||||
* through the @native-sdk/core transpiler; "app-dual" is a wave-2
|
||||
* through the @native-sdk/core frontend; "app-dual" is a wave-2
|
||||
* dual-track case: ONE language-blind spec that runs on both authoring
|
||||
* tracks — the ts track scaffolds a full TypeScript app
|
||||
* (`native init --frontend native --template ts-core`), the zig track the
|
||||
@@ -96,7 +96,7 @@ export interface MarkupCheckCheck extends CheckCommon {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the @native-sdk/core transpiler on the workspace core (ts-core cases).
|
||||
* Run the @native-sdk/core frontend on the workspace core (ts-core cases).
|
||||
* Pass = the module typechecks (tsc semantics), passes every subset rule
|
||||
* (NS1001-NS1050), and emits Zig. The diagnostics tail is kept as evidence,
|
||||
* so violation taxonomy can be read off failing runs.
|
||||
|
||||
+16
-3
@@ -10,7 +10,21 @@ native build # produce a ReleaseFast binary in zig-out/bin/
|
||||
|
||||
(In this repository the CLI is `zig-out/bin/native`, built by `zig build` at the root.) A handful of examples own a `build.zig` because they genuinely outgrow the generated graph — each one's build file opens with the reason.
|
||||
|
||||
## Zero-config apps (native-rendered)
|
||||
## Start here: TypeScript + Native markup
|
||||
|
||||
TypeScript is the primary app-authoring language. A new `native init my_app` project has `src/core.ts`, `src/app.native`, and `app.zon`; the core compiles ahead of time to native code, so no JS runtime ships in the app. These examples are the clearest substantial references for that path:
|
||||
|
||||
| Example | Shows |
|
||||
| --- | --- |
|
||||
| `chatbot` | Multi-module TypeScript core, text editing, streaming `Cmd.fetch`, environment messages, and deterministic replay. |
|
||||
| `relational-notes` | Append-only SQLite migrations, build-time checked SQL, generated typed transactions and page decoders, FTS5, and live queries. |
|
||||
| `gpu-components` | Isolated interactive Native UI specimens, disclosure trees, anchored menus, and controlled component state. |
|
||||
| `soundboard-ts` | Full music player: audio effects, timers, search, assets, native context menus, and adaptive markup. |
|
||||
| `system-monitor-ts` | Subprocess effects, timers, parsing, tables, charts, controlled scroll, and confirmation flows. |
|
||||
|
||||
The `-ts` suffix is historical: `soundboard-ts` and `system-monitor-ts` distinguish ports from older Zig originals in the same catalog. Chatbot was introduced as a TypeScript-only example and follows the unsuffixed default. New TypeScript apps need no suffix because TypeScript is the default. Many unsuffixed showcase apps predate that default and still use `src/main.zig`; use them for their feature or visual patterns, not as evidence that new app logic should be Zig.
|
||||
|
||||
## Earlier native-rendered showcase apps (Zig cores)
|
||||
|
||||
| Example | Shows |
|
||||
| --- | --- |
|
||||
@@ -26,7 +40,6 @@ native build # produce a ReleaseFast binary in zig-out/bin/
|
||||
| `system-monitor` | Live process sampling, confirmation dialogs, a settings window. |
|
||||
| `gpu-surface` | A Metal-backed GPU surface composed beside native controls and WebView content. |
|
||||
| `gpu-dashboard` | Native chrome, a GPU surface, and a retained canvas display list. |
|
||||
| `gpu-components` | The retained GPU widget controls in one native-first component lab. |
|
||||
| `canvas-preview` | Canvas + WebView in one window, panes snapped to canvas anchors, a status item. |
|
||||
| `effects-probe` | The effect system live: spawn/fetch/file effects, cancellation, worker wakes. |
|
||||
| `menu-bar` | The menu-bar app lifecycle: `close_policy = "hide"`, a status item whose Open/Quit rows drive `fx.showWindow`/`fx.quitApp`, Dock reopen. |
|
||||
@@ -48,4 +61,4 @@ native build # produce a ReleaseFast binary in zig-out/bin/
|
||||
|
||||
`mobile-shell`, `ios`, and `android` are mobile host projects (Xcode/Gradle shells plus shared `app.zon` metadata) rather than desktop app directories.
|
||||
|
||||
Start with `habits` for the native-rendered markup path, or `hello` for the WebView path. Move to `webview` when you need native commands or WebView policy, `capabilities` for guarded OS services, the GPU trio when you want custom-rendered or retained-canvas panes, and a frontend example when building a real web frontend.
|
||||
Start with `native init` for a small TypeScript + Native markup app, then use `chatbot`, `gpu-components`, `soundboard-ts`, or `system-monitor-ts` according to the feature you need. Use `habits` when you specifically want the smallest Zig-core equivalent, `hello` for the lower-level WebView path, `webview` for native commands or WebView policy, `capabilities` for guarded OS services, and `gpu-surface` or `gpu-dashboard` for custom-rendered or retained-canvas panes.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Native SDK ai-chat-ts example
|
||||
|
||||
A chat client for an OpenAI-compatible chat-completions endpoint, authored entirely in **TypeScript + Native markup**. Zero Zig: the logic tier is the app-core subset under `src/`, transpiled to native at build time as one module; `src/app.native` is the whole view tier and `app.zon` the manifest. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
|
||||
|
||||
This is the reference answer to "can a TypeScript core call an AI API?": the network surface is one `Cmd.fetch` with a real `Authorization: Bearer <key>` header built at runtime from the launch environment, the JSON wire format is pure byte math in the subset, and because the whole exchange is effect data, a recorded conversation **replays byte-identically with zero network and zero env reads** — the e2e suite pins the exact request bytes and replays a two-turn conversation, transport failure and retry included, with no endpoint in the room and none of the launch variables set.
|
||||
|
||||
The core is two modules plus one SDK library:
|
||||
|
||||
- `src/core.ts` — the entry module: Model (the conversation, the composer, the request phase, the launch configuration), Msg, update, the env channel, and every exported binding helper.
|
||||
- `src/api.ts` — the chat-completions wire format over bytes: request encoding (JSON escaping included) and response parsing (`choices[0].message.content` on success, `error.message` on failure; anything malformed is `null`, never a half-parsed conversation).
|
||||
- `@native-sdk/core/text` — the SDK's byte-splice text engine, transpiled in for the composer's caret/selection/IME fidelity.
|
||||
|
||||
```sh
|
||||
NATIVE_SDK_CHAT_ENDPOINT="http://127.0.0.1:11434/v1/chat/completions" \
|
||||
NATIVE_SDK_CHAT_MODEL="<your model name>" \
|
||||
NATIVE_SDK_CHAT_API_KEY="local" \
|
||||
native dev # run the real app
|
||||
native dev --core --script dev-script.ndjson # the core-logic loop under node - no renderer, no network
|
||||
native check # subset-check the core's import graph + markup + app.zon
|
||||
```
|
||||
|
||||
The end-to-end proof battery lives in the SDK repo (`tests/ts-core/ai_chat_e2e_tests.zig`, run by `zig build test-ts-core-e2e`): it drives this example's real core and shipping markup headlessly through the teaching state (zero fetches without configuration), a scripted conversation with the request bytes pinned (`Authorization` header included), the in-flight guard, every failure shape, and record→replay with the launch variables unset and changed.
|
||||
|
||||
## Configuration: the env channel
|
||||
|
||||
The endpoint, model, and key arrive through the core's `envMsgs` channel — one journaled Msg per variable at install. The core never reads the environment (that would break determinism), **no endpoint is baked in, and no key exists anywhere in this tree**: until all three variables are present and non-empty, the app shows a setup panel naming exactly what is missing and issues zero requests.
|
||||
|
||||
- **`NATIVE_SDK_CHAT_ENDPOINT`** — the full chat-completions URL (for a local runtime, typically `http://127.0.0.1:<port>/v1/chat/completions`).
|
||||
- **`NATIVE_SDK_CHAT_MODEL`** — the model name the endpoint expects in the request body.
|
||||
- **`NATIVE_SDK_CHAT_API_KEY`** — the bearer token, sent as a standard `Authorization: Bearer <key>` header. Local OpenAI-compatible runtimes ignore auth; any placeholder satisfies the guard.
|
||||
|
||||
Record/replay journals these deliveries: a session recorded with the variables set replays byte-identically on a machine where they are unset or different — the recorded values feed from the journal, and replay never reads the environment.
|
||||
|
||||
## Where this example is honest about v1 boundaries
|
||||
|
||||
Every line below is a decided posture, listed on purpose:
|
||||
|
||||
- **The reply arrives whole, not streamed.** `Cmd.fetch` is buffered by design in v1 — one request, one `{ status, body }` result Msg. The UI shows an honest waiting state instead of a token stream. The effect engine underneath already frames streamed response bodies into line Msgs (the Zig effects channel's `.stream` fetch — exactly the shape SSE token streams arrive in); surfacing that in the TS Cmd vocabulary is the named roadmap item. Buffered is also what makes the replay trick trivial: one journaled result per request.
|
||||
- **A failed request keeps the conversation.** Every failure shape — a non-200 status (the endpoint's own `error.message` surfaces when the body carries one), a 200 whose body does not parse, a transport failure with its machine-readable reason — lands in one failed state with the history intact and a Retry that re-sends the same conversation.
|
||||
- **One request in flight, by construction.** `phase === "sending"` guards every send path in update (the Send button binds the same guard), and the `"chat"` effect key would reject a duplicate at the engine even if update misbehaved. A send blocked by the guard loses nothing — the draft survives.
|
||||
- **Long conversations eventually hit the request bound.** The engine's fetch body bound is 64 KiB; a conversation that outgrows it is rejected by the engine at runtime and lands in the failed state with a reason. Clear starts fresh. (History trimming/summarizing is app policy, deliberately not built in here.)
|
||||
- **The conversation is not persisted.** The Model is the session; `Cmd.writeFile` + a boot-time `Cmd.readFile` is the standard persistence pattern when an app wants history across launches.
|
||||
- **Desktop only.** TypeScript cores build desktop apps today.
|
||||
- **The encoder's helpers return byte arrays instead of appending to a shared buffer.** Local mutation ends at the first escape — an array passed to another function is no longer yours to mutate (the NS1051 "mutates after the array escaped" rule) — so `encodeChatRequest` assembles the request from values its helpers return, in one literal, rather than handing a parts buffer around between pushes.
|
||||
@@ -1,39 +0,0 @@
|
||||
# The chat client's core-logic loop, headless: replay with
|
||||
# native dev --core --script dev-script.ndjson
|
||||
# Msgs dispatch into update; the endpoint's answers are ordinary Msgs, so
|
||||
# the responses are fed back by hand exactly as the transcript's
|
||||
# `cmd fetch ...` lines invite — the same loop the native app runs, with
|
||||
# you standing in for the network.
|
||||
|
||||
# The launch configuration arrives through the env channel as ordinary
|
||||
# Msgs (under the real app the generated wiring dispatches these from the
|
||||
# environment at install). A local placeholder endpoint - nothing dials
|
||||
# out under the core host.
|
||||
{"kind":"endpoint_set","value":{"$bytes":"http://127.0.0.1:11434/v1/chat/completions"}}
|
||||
{"kind":"model_set","value":{"$bytes":"local-model"}}
|
||||
{"kind":"key_set","value":{"$bytes":"local"}}
|
||||
|
||||
# Type a message (the composer runs the SDK byte-splice text engine) and
|
||||
# send. The transcript shows the fetch command whole: POST, the endpoint,
|
||||
# the runtime-built "authorization: Bearer <key>" header, and the JSON
|
||||
# body - system prompt first, then the history.
|
||||
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"Say hi in two words"}}}
|
||||
{"kind":"send"}
|
||||
|
||||
# The endpoint's answer, fed back by hand: choices[0].message.content
|
||||
# parses into the assistant turn (escapes decode - note the \n).
|
||||
{"kind":"chat_response","status":200,"body":{"$bytes":"{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Hi\\nthere!\"}}]}"}}
|
||||
|
||||
# A second turn grows the history: watch the request body carry both
|
||||
# earlier turns before the new question.
|
||||
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"And a follow-up?"}}}
|
||||
{"kind":"send"}
|
||||
|
||||
# This time the endpoint fails with its own error body - the failed
|
||||
# state keeps the history and surfaces error.message as the reason.
|
||||
{"kind":"chat_response","status":500,"body":{"$bytes":"{\"error\":{\"message\":\"model overloaded\",\"type\":\"server_error\"}}"}}
|
||||
|
||||
# Retry re-sends the SAME conversation (no new turn); a success resolves
|
||||
# it into the fourth turn.
|
||||
{"kind":"retry"}
|
||||
{"kind":"chat_response","status":200,"body":{"$bytes":"{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Certainly.\"}}]}"}}
|
||||
@@ -1,120 +0,0 @@
|
||||
<!-- The chat client's whole view tier: one markup file over the TS core's
|
||||
model, bound by the names core.ts wrote (fields and exported helpers
|
||||
bind verbatim). The
|
||||
header carries the model badge and Clear, the conversation is a
|
||||
controlled scroll of role bubbles (user right/accent, assistant
|
||||
left/surface) with honest sending and failed rows, and the composer
|
||||
is a text-field on the core's byte-splice engine — Enter and the
|
||||
Send button dispatch the same `send` arm. Until the three launch
|
||||
variables arrive through the env channel, the teaching panel
|
||||
explains the setup and the app issues zero requests. -->
|
||||
<column background="background">
|
||||
<row height="52" padding="12" gap="10" cross="center" background="surface" label="Chat header">
|
||||
<text label="AI Chat"><span weight="bold" scale="1.1">AI Chat</span></text>
|
||||
<badge variant="secondary">{modelLabel}</badge>
|
||||
<spacer grow="1" />
|
||||
<if test="{sending}">
|
||||
<text size="sm" foreground="text_muted">waiting for the model…</text>
|
||||
</if>
|
||||
<button size="sm" variant="ghost" icon="trash" disabled="{clearDisabled}" on-press="clear" label="Clear conversation">Clear</button>
|
||||
</row>
|
||||
<separator />
|
||||
<if test="{unconfigured}">
|
||||
<!-- The teaching state: no endpoint is baked in and no request ever
|
||||
leaves an unconfigured app — the panel names exactly what is
|
||||
missing. -->
|
||||
<column grow="1" padding="24" main="center" cross="center" label="Setup">
|
||||
<panel padding="24" background="surface" radius="lg" width="520" label="Connect a model">
|
||||
<column gap="12">
|
||||
<text><span weight="bold">Connect a model</span></text>
|
||||
<text size="sm" foreground="text_muted">This app talks to an OpenAI-compatible chat-completions endpoint. Set all three variables and relaunch — the core reads them once, at install, through the env channel.</text>
|
||||
<row gap="8" cross="center">
|
||||
<text size="sm" grow="1">NATIVE_SDK_CHAT_ENDPOINT</text>
|
||||
<if test="{endpointMissing}">
|
||||
<text size="sm" foreground="destructive">missing</text>
|
||||
</if>
|
||||
<else>
|
||||
<text size="sm" foreground="success">set</text>
|
||||
</else>
|
||||
</row>
|
||||
<row gap="8" cross="center">
|
||||
<text size="sm" grow="1">NATIVE_SDK_CHAT_MODEL</text>
|
||||
<if test="{modelMissing}">
|
||||
<text size="sm" foreground="destructive">missing</text>
|
||||
</if>
|
||||
<else>
|
||||
<text size="sm" foreground="success">set</text>
|
||||
</else>
|
||||
</row>
|
||||
<row gap="8" cross="center">
|
||||
<text size="sm" grow="1">NATIVE_SDK_CHAT_API_KEY</text>
|
||||
<if test="{keyMissing}">
|
||||
<text size="sm" foreground="destructive">missing</text>
|
||||
</if>
|
||||
<else>
|
||||
<text size="sm" foreground="success">set</text>
|
||||
</else>
|
||||
</row>
|
||||
<text size="sm" foreground="text_muted">The README shows the full setup, including local OpenAI-compatible runtimes.</text>
|
||||
</column>
|
||||
</panel>
|
||||
</column>
|
||||
</if>
|
||||
<else>
|
||||
<scroll grow="1" label="Conversation" value="{chatScrollTop}" on-scroll="chat_scrolled">
|
||||
<column padding="24" gap="10">
|
||||
<if test="{emptyConversation}">
|
||||
<panel padding="24" background="surface" radius="lg" label="Empty conversation">
|
||||
<column gap="6">
|
||||
<text>Ask anything</text>
|
||||
<text size="sm" foreground="text_muted">The reply arrives whole — responses are buffered in v1, not streamed.</text>
|
||||
</column>
|
||||
</panel>
|
||||
</if>
|
||||
<for each="turnRows" as="t" key="id">
|
||||
<if test="{t.user}">
|
||||
<row key="{t.id}" gap="8" label="You said">
|
||||
<spacer grow="1" min-width="64" />
|
||||
<panel padding="12" background="accent" radius="lg">
|
||||
<text wrap="true" foreground="accent_text">{t.text}</text>
|
||||
</panel>
|
||||
</row>
|
||||
</if>
|
||||
<else>
|
||||
<row key="{t.id}" gap="8" label="The model said">
|
||||
<panel padding="12" background="surface" radius="lg">
|
||||
<text wrap="true">{t.text}</text>
|
||||
</panel>
|
||||
<spacer grow="1" min-width="64" />
|
||||
</row>
|
||||
</else>
|
||||
</for>
|
||||
<if test="{sending}">
|
||||
<row gap="8" label="Reply pending">
|
||||
<panel padding="12" background="surface" radius="lg">
|
||||
<text foreground="text_muted">…</text>
|
||||
</panel>
|
||||
<spacer grow="1" min-width="64" />
|
||||
</row>
|
||||
</if>
|
||||
<if test="{failed}">
|
||||
<panel padding="12" background="surface" radius="lg" label="Request failed">
|
||||
<row gap="10" cross="center">
|
||||
<icon name="alert" width="14" height="14" foreground="destructive" />
|
||||
<column gap="2" grow="1">
|
||||
<text size="sm" foreground="destructive">Request failed</text>
|
||||
<text size="sm" foreground="text_muted">{failReasonLabel}</text>
|
||||
</column>
|
||||
<button size="sm" variant="ghost" icon="refresh-cw" on-press="retry" label="Retry request">Retry</button>
|
||||
</row>
|
||||
</panel>
|
||||
</if>
|
||||
</column>
|
||||
</scroll>
|
||||
<separator />
|
||||
<row padding="12" gap="8" cross="center" background="surface" label="Composer">
|
||||
<text-field grow="1" text="{draftText}" placeholder="Message the model…" on-input="draft_edit" on-submit="send" label="Message" />
|
||||
<button variant="primary" icon="send" disabled="{sendDisabled}" on-press="send" label="Send message">Send</button>
|
||||
</row>
|
||||
</else>
|
||||
</column>
|
||||
@@ -1,409 +0,0 @@
|
||||
// ai-chat-ts core: a chat client for an OpenAI-compatible chat-completions
|
||||
// endpoint, authored entirely in the TypeScript app-core subset. Zero Zig
|
||||
// in this tree: the build transpiles this module and src/api.ts,
|
||||
// src/app.native is the whole view, app.zon the manifest.
|
||||
//
|
||||
// The core is two modules plus one SDK library, all under src/:
|
||||
//
|
||||
// core.ts (this file) Model, Msg, update, the wiring channels, and
|
||||
// every exported binding helper — the entry module is the
|
||||
// app's public face (markup and node both see its exports)
|
||||
// api.ts the chat-completions wire format in pure bytes: request
|
||||
// encoding, response parsing (choices[0].message.content and
|
||||
// error.message — exactly the fields the app reads)
|
||||
// @native-sdk/core/text the SDK's byte-splice text engine, transpiled
|
||||
// in for the composer's caret/selection/IME fidelity
|
||||
//
|
||||
// The whole network surface is ONE effect: `Cmd.fetch` on the "chat" key,
|
||||
// buffered (fetch streaming is consciously not in v1 — the reply arrives
|
||||
// whole; the README frames the roadmap). The in-flight discipline is
|
||||
// model-first: `phase === "sending"` blocks every re-send in update, so a
|
||||
// second request cannot exist while one is out — and the "chat" key backs
|
||||
// that up at the engine (a duplicate live key would be rejected, never
|
||||
// doubled).
|
||||
//
|
||||
// The endpoint, model name, and API key arrive through the `envMsgs`
|
||||
// channel as journaled Msgs at install — the core never reads the
|
||||
// environment (NS1005), which is exactly why a recorded conversation
|
||||
// replays byte-identically on a machine with none of the variables set.
|
||||
|
||||
import { Cmd, asciiBytes, type EnvMsg } from "@native-sdk/core";
|
||||
import {
|
||||
applyTextInputEvent,
|
||||
clampedInsertEvent,
|
||||
trimAsciiSpaces,
|
||||
type TextEditState,
|
||||
type TextInputEvent,
|
||||
} from "@native-sdk/core/text";
|
||||
// The SDK-provided scroll-state record (the shape markup's on-scroll
|
||||
// matches structurally - imported, so no in-file mirror can drift).
|
||||
import { type ScrollState } from "@native-sdk/core/events";
|
||||
import {
|
||||
bearerToken,
|
||||
encodeChatRequest,
|
||||
parseChatContent,
|
||||
parseErrorMessage,
|
||||
type Bytes,
|
||||
type Turn,
|
||||
} from "./api.ts";
|
||||
|
||||
/// The conversation's standing instruction, first in every request's
|
||||
/// message list. One constant, versioned with the app — not model state,
|
||||
/// so replay and the request pins never depend on it drifting.
|
||||
const SYSTEM_PROMPT = asciiBytes(
|
||||
"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.",
|
||||
);
|
||||
|
||||
/// The composer's byte capacity — comfortably under the engine's 64 KiB
|
||||
/// request-body bound with a long conversation around it.
|
||||
const MAX_DRAFT = 4096;
|
||||
|
||||
/// Assigning the scroll binding a value past the content clamps to the
|
||||
/// bottom — how a new message keeps the latest turn in view.
|
||||
const SCROLL_BOTTOM = 1000000;
|
||||
|
||||
// -------------------------------------------------------------- composer
|
||||
// The fixed-capacity editor state for the message field: the SDK text
|
||||
// engine does the byte splicing; this wrapper is the app's flat committed
|
||||
// shape for it (compStart -1 = no composition). Immutable: composerApply
|
||||
// returns a new value.
|
||||
|
||||
export interface ComposerDraft {
|
||||
readonly bytes: Bytes;
|
||||
readonly anchor: number;
|
||||
readonly focus: number;
|
||||
readonly compStart: number; // -1 when no composition
|
||||
readonly compEnd: number;
|
||||
}
|
||||
|
||||
function composerInit(): ComposerDraft {
|
||||
return { bytes: new Uint8Array(0), anchor: 0, focus: 0, compStart: -1, compEnd: -1 };
|
||||
}
|
||||
|
||||
function composerState(d: ComposerDraft): TextEditState {
|
||||
return {
|
||||
text: d.bytes,
|
||||
selection: { anchor: d.anchor, focus: d.focus },
|
||||
composition: d.compStart >= 0 ? { start: d.compStart, end: d.compEnd } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function composerApply(d: ComposerDraft, event: TextInputEvent): ComposerDraft {
|
||||
const state = composerState(d);
|
||||
const next = applyTextInputEvent(state, event, MAX_DRAFT);
|
||||
if (next === null) {
|
||||
// Over-capacity: clamp an insert to the bytes that fit (refuse-whole
|
||||
// for everything else) — the runtime TextBuffer's contract.
|
||||
const clamped = clampedInsertEvent(state, event, MAX_DRAFT);
|
||||
if (clamped === null) return d;
|
||||
const nextClamped = applyTextInputEvent(state, clamped, MAX_DRAFT);
|
||||
if (nextClamped === null) return d;
|
||||
// Composition bounds land in i64-classed slots: bind them, guard
|
||||
// the range (an ordered comparison excludes NaN), and state
|
||||
// wholeness with Math.trunc at the write; -1 stays the no-composition
|
||||
// sentinel.
|
||||
const clampedStart = nextClamped.composition !== null ? nextClamped.composition.start : -1;
|
||||
const clampedEnd = nextClamped.composition !== null ? nextClamped.composition.end : -1;
|
||||
return {
|
||||
bytes: nextClamped.text,
|
||||
anchor: nextClamped.selection.anchor,
|
||||
focus: nextClamped.selection.focus,
|
||||
compStart: clampedStart >= -1 && clampedStart <= 9007199254740991 ? Math.trunc(clampedStart) : -1,
|
||||
compEnd: clampedEnd >= -1 && clampedEnd <= 9007199254740991 ? Math.trunc(clampedEnd) : -1,
|
||||
};
|
||||
}
|
||||
const nextStart = next.composition !== null ? next.composition.start : -1;
|
||||
const nextEnd = next.composition !== null ? next.composition.end : -1;
|
||||
return {
|
||||
bytes: next.text,
|
||||
anchor: next.selection.anchor,
|
||||
focus: next.selection.focus,
|
||||
compStart: nextStart >= -1 && nextStart <= 9007199254740991 ? Math.trunc(nextStart) : -1,
|
||||
compEnd: nextEnd >= -1 && nextEnd <= 9007199254740991 ? Math.trunc(nextEnd) : -1,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ model
|
||||
|
||||
export type Phase = "idle" | "sending" | "failed";
|
||||
|
||||
export interface Model {
|
||||
/// The conversation, oldest first — user and assistant turns alike.
|
||||
/// Committed state, so record→replay carries the whole conversation.
|
||||
readonly turns: readonly Turn[];
|
||||
readonly nextId: number;
|
||||
/// The request lifecycle: `sending` is the in-flight guard (every
|
||||
/// re-send path checks it), `failed` keeps the history and shows the
|
||||
/// reason until the next send.
|
||||
readonly phase: Phase;
|
||||
/// Why the last request failed: the transport reason (`timed_out`,
|
||||
/// `connect_failed`, ...), the endpoint's own error.message, or the
|
||||
/// HTTP status line — never empty in the failed phase.
|
||||
readonly failReason: Bytes;
|
||||
readonly draft: ComposerDraft;
|
||||
/// The launch configuration (the envMsgs channel): the full
|
||||
/// chat-completions URL, the model name, and the API key. All three
|
||||
/// empty until their variables arrive; the app teaches setup until
|
||||
/// every one is non-empty.
|
||||
readonly endpoint: Bytes;
|
||||
readonly modelName: Bytes;
|
||||
readonly apiKey: Bytes;
|
||||
/// The conversation scroll offset, echoed from markup's `on-scroll`
|
||||
/// and pushed past the content on every new turn (the clamp lands it
|
||||
/// at the bottom) — the controlled-scroll shape.
|
||||
readonly chatScrollTop: number;
|
||||
}
|
||||
|
||||
export function initialModel(): Model {
|
||||
return {
|
||||
turns: [],
|
||||
nextId: 1,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
draft: composerInit(),
|
||||
endpoint: new Uint8Array(0),
|
||||
modelName: new Uint8Array(0),
|
||||
apiKey: new Uint8Array(0),
|
||||
chatScrollTop: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- msg
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "draft_edit"; readonly edit: TextInputEvent }
|
||||
/// The send gesture: the composer's Enter (markup `on-submit`) and the
|
||||
/// Send button dispatch the same arm.
|
||||
| { readonly kind: "send" }
|
||||
/// Re-issue the failed request over the history as it stands (the
|
||||
/// unanswered user turn is already the last entry).
|
||||
| { readonly kind: "retry" }
|
||||
| { readonly kind: "clear" }
|
||||
/// The delivered HTTP response, any status — the fetch ok arm.
|
||||
| { readonly kind: "chat_response"; readonly status: number; readonly body: Bytes }
|
||||
/// The transport failure — the fetch err arm's machine-readable reason.
|
||||
| { readonly kind: "chat_failed"; readonly reason: Bytes }
|
||||
| { readonly kind: "chat_scrolled"; readonly scroll: ScrollState }
|
||||
| { readonly kind: "endpoint_set"; readonly value: Bytes }
|
||||
| { readonly kind: "model_set"; readonly value: Bytes }
|
||||
| { readonly kind: "key_set"; readonly value: Bytes };
|
||||
|
||||
// --------------------------------------------------- host-event channels
|
||||
|
||||
/// The launch configuration channel: each variable present at launch
|
||||
/// dispatches one journaled Msg right after boot. NO default endpoint
|
||||
/// and NO baked key exist anywhere in this tree — an unconfigured app
|
||||
/// says so on screen instead of dialing a stranger.
|
||||
export const envMsgs: readonly EnvMsg<Msg>[] = [
|
||||
{ env: "NATIVE_SDK_CHAT_ENDPOINT", msg: "endpoint_set" },
|
||||
{ env: "NATIVE_SDK_CHAT_MODEL", msg: "model_set" },
|
||||
{ env: "NATIVE_SDK_CHAT_API_KEY", msg: "key_set" },
|
||||
];
|
||||
|
||||
/// Update-only state: host-fired Msg arms and the fields markup reads
|
||||
/// through the exported derived helpers instead of directly.
|
||||
export const viewUnbound = [
|
||||
"chat_response",
|
||||
"chat_failed",
|
||||
"endpoint_set",
|
||||
"model_set",
|
||||
"key_set",
|
||||
"turns",
|
||||
"nextId",
|
||||
"phase",
|
||||
"failReason",
|
||||
"draft",
|
||||
"endpoint",
|
||||
"modelName",
|
||||
"apiKey",
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------- derived
|
||||
|
||||
function isConfigured(model: Model): boolean {
|
||||
return model.endpoint.length > 0 && model.modelName.length > 0 && model.apiKey.length > 0;
|
||||
}
|
||||
|
||||
/// The teaching state: some launch variable is missing, so the app can
|
||||
/// only explain how to connect a model — and issues zero requests.
|
||||
export function unconfigured(model: Model): boolean {
|
||||
return !isConfigured(model);
|
||||
}
|
||||
|
||||
export function endpointMissing(model: Model): boolean {
|
||||
return model.endpoint.length === 0;
|
||||
}
|
||||
|
||||
export function modelMissing(model: Model): boolean {
|
||||
return model.modelName.length === 0;
|
||||
}
|
||||
|
||||
export function keyMissing(model: Model): boolean {
|
||||
return model.apiKey.length === 0;
|
||||
}
|
||||
|
||||
export function sending(model: Model): boolean {
|
||||
return model.phase === "sending";
|
||||
}
|
||||
|
||||
export function failed(model: Model): boolean {
|
||||
return model.phase === "failed";
|
||||
}
|
||||
|
||||
export function failReasonLabel(model: Model): Bytes {
|
||||
return model.failReason;
|
||||
}
|
||||
|
||||
export function draftText(model: Model): Bytes {
|
||||
return model.draft.bytes;
|
||||
}
|
||||
|
||||
export function emptyConversation(model: Model): boolean {
|
||||
return model.turns.length === 0;
|
||||
}
|
||||
|
||||
/// The header's model badge: the configured name, or the gap it teaches.
|
||||
export function modelLabel(model: Model): Bytes {
|
||||
return model.modelName.length > 0 ? model.modelName : asciiBytes("no model configured");
|
||||
}
|
||||
|
||||
export function sendDisabled(model: Model): boolean {
|
||||
return model.phase === "sending" || !isConfigured(model);
|
||||
}
|
||||
|
||||
export function clearDisabled(model: Model): boolean {
|
||||
return model.phase === "sending" || model.turns.length === 0;
|
||||
}
|
||||
|
||||
/// One conversation row for markup's `for each`: the role flag picks the
|
||||
/// bubble side and colors.
|
||||
export interface TurnRow {
|
||||
readonly id: number;
|
||||
readonly user: boolean;
|
||||
readonly text: Bytes;
|
||||
}
|
||||
|
||||
export function turnRows(model: Model): readonly TurnRow[] {
|
||||
return model.turns.map((t) => ({ id: t.id, user: t.role === "user", text: t.text }));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- update
|
||||
|
||||
export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "draft_edit":
|
||||
return [{ ...model, draft: composerApply(model.draft, msg.edit) }, Cmd.none];
|
||||
case "send": {
|
||||
// The in-flight guard: one request at a time, by model state — a
|
||||
// second send while one is out is a no-op, so the "chat" key can
|
||||
// never collide at the engine.
|
||||
if (!isConfigured(model) || model.phase === "sending") return [model, Cmd.none];
|
||||
const text = trimAsciiSpaces(model.draft.bytes);
|
||||
if (text.length === 0) return [model, Cmd.none];
|
||||
const turns: readonly Turn[] = [...model.turns, { id: model.nextId, role: "user", text: text }];
|
||||
return [
|
||||
{
|
||||
...model,
|
||||
turns: turns,
|
||||
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
|
||||
phase: "sending",
|
||||
failReason: new Uint8Array(0),
|
||||
draft: composerInit(),
|
||||
chatScrollTop: SCROLL_BOTTOM,
|
||||
},
|
||||
Cmd.fetch(
|
||||
{
|
||||
url: model.endpoint,
|
||||
method: "POST",
|
||||
// The bearer token is a RUNTIME header value (built from the
|
||||
// launch-supplied key); header names stay compile-time.
|
||||
headers: { authorization: bearerToken(model.apiKey), "content-type": "application/json" },
|
||||
body: encodeChatRequest(model.modelName, SYSTEM_PROMPT, turns),
|
||||
timeoutMs: 120000,
|
||||
},
|
||||
{ key: "chat", ok: "chat_response", err: "chat_failed" },
|
||||
),
|
||||
];
|
||||
}
|
||||
case "retry": {
|
||||
// Re-send the conversation as it stands: only from the failed
|
||||
// state, and only when the last turn is the unanswered user turn.
|
||||
if (model.phase !== "failed" || !isConfigured(model)) return [model, Cmd.none];
|
||||
if (model.turns.length === 0) return [model, Cmd.none];
|
||||
if (model.turns[model.turns.length - 1].role !== "user") return [model, Cmd.none];
|
||||
return [
|
||||
{ ...model, phase: "sending", failReason: new Uint8Array(0) },
|
||||
Cmd.fetch(
|
||||
{
|
||||
url: model.endpoint,
|
||||
method: "POST",
|
||||
headers: { authorization: bearerToken(model.apiKey), "content-type": "application/json" },
|
||||
body: encodeChatRequest(model.modelName, SYSTEM_PROMPT, model.turns),
|
||||
timeoutMs: 120000,
|
||||
},
|
||||
{ key: "chat", ok: "chat_response", err: "chat_failed" },
|
||||
),
|
||||
];
|
||||
}
|
||||
case "clear": {
|
||||
if (model.phase === "sending" || model.turns.length === 0) return [model, Cmd.none];
|
||||
return [{
|
||||
...model,
|
||||
turns: [],
|
||||
nextId: 1,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
chatScrollTop: 0,
|
||||
}, Cmd.none];
|
||||
}
|
||||
case "chat_response": {
|
||||
// The "chat" key carries exactly one live request and the sending
|
||||
// guard blocks re-sends, so a response outside the sending phase
|
||||
// can only be stale — drop it rather than corrupt the history.
|
||||
if (model.phase !== "sending") return [model, Cmd.none];
|
||||
if (msg.status === 200) {
|
||||
const content = parseChatContent(msg.body);
|
||||
if (content === null) {
|
||||
// A 200 whose body is not a chat completion is a failed
|
||||
// request, never a half-parsed conversation.
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: asciiBytes("the response did not parse as a chat completion"),
|
||||
}, Cmd.none];
|
||||
}
|
||||
return [{
|
||||
...model,
|
||||
turns: [...model.turns, { id: model.nextId, role: "assistant", text: content }],
|
||||
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
|
||||
phase: "idle",
|
||||
chatScrollTop: SCROLL_BOTTOM,
|
||||
}, Cmd.none];
|
||||
}
|
||||
// Any other status is a delivered response whose meaning is "the
|
||||
// endpoint said no": surface its own error.message when the body
|
||||
// carries one, the bare status line when it does not.
|
||||
const message = parseErrorMessage(msg.body);
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: message ?? asciiBytes(`the endpoint answered HTTP ${msg.status}`),
|
||||
}, Cmd.none];
|
||||
}
|
||||
case "chat_failed":
|
||||
// The transport reason is machine-readable (`timed_out`,
|
||||
// `connect_failed`, `truncated`, ...) — shown as-is, never silence.
|
||||
return [{ ...model, phase: "failed", failReason: msg.reason }, Cmd.none];
|
||||
case "chat_scrolled":
|
||||
// The controlled-scroll echo: the applied offset lands in the
|
||||
// model, so the next rebuild's `value` binding never fights the
|
||||
// runtime.
|
||||
return [{ ...model, chatScrollTop: msg.scroll.offsetY }, Cmd.none];
|
||||
case "endpoint_set":
|
||||
return [{ ...model, endpoint: msg.value }, Cmd.none];
|
||||
case "model_set":
|
||||
return [{ ...model, modelName: msg.value }, Cmd.none];
|
||||
case "key_set":
|
||||
return [{ ...model, apiKey: msg.value }, Cmd.none];
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,7 @@ int native_sdk_app_text_input_state(void *app, native_sdk_text_input_state_t *ou
|
||||
typedef double (*native_sdk_text_measure_fn)(void *context, uint64_t font_id, double size, const char *text, uintptr_t text_len);
|
||||
int native_sdk_app_set_text_measure(void *app, native_sdk_text_measure_fn measure, void *context);
|
||||
int native_sdk_app_set_automation_dir(void *app, const char *path, uintptr_t len);
|
||||
int native_sdk_app_set_data_root(void *app, const char *path, uintptr_t len);
|
||||
int native_sdk_app_render_pixel_size(void *app, float scale, native_sdk_canvas_pixels_t *out);
|
||||
int native_sdk_app_render_pixels(void *app, float scale, uint8_t *pixels, uintptr_t pixels_len, native_sdk_canvas_pixels_t *out);
|
||||
// Incremental sibling of native_sdk_app_render_pixels for a host that
|
||||
|
||||
@@ -204,9 +204,12 @@ fn linkPlatform(b: *std.Build, target: std.Build.ResolvedTarget, app_mod: *std.B
|
||||
},
|
||||
}
|
||||
if (b.sysroot) |sysroot| app_mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "System/Library/Frameworks" }) });
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/capture_info_plist.c"), .flags = &.{} });
|
||||
app_mod.linkFramework("AppKit", .{});
|
||||
// The audio playback service (the AppKit host's single AVPlayer).
|
||||
app_mod.linkFramework("AVFoundation", .{});
|
||||
app_mod.linkFramework("CoreMedia", .{});
|
||||
app_mod.linkFramework("ScreenCaptureKit", .{ .weak = true });
|
||||
// CVPixelBuffer for the video frame path (the AppKit host's
|
||||
// AVPlayerItemVideoOutput frames are real CoreVideo symbols).
|
||||
app_mod.linkFramework("CoreVideo", .{});
|
||||
@@ -278,10 +281,13 @@ fn linkPlatform(b: *std.Build, target: std.Build.ResolvedTarget, app_mod: *std.B
|
||||
app_mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
|
||||
},
|
||||
}
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/windows/gpu_surface_renderer.cpp"), .flags = &.{"-std=c++17"} });
|
||||
app_mod.linkSystemLibrary("c", .{});
|
||||
app_mod.linkSystemLibrary("c++", .{});
|
||||
app_mod.linkSystemLibrary("user32", .{});
|
||||
app_mod.linkSystemLibrary("gdi32", .{});
|
||||
app_mod.linkSystemLibrary("d2d1", .{});
|
||||
app_mod.linkSystemLibrary("dwrite", .{});
|
||||
app_mod.linkSystemLibrary("imm32", .{});
|
||||
app_mod.linkSystemLibrary("comctl32", .{});
|
||||
app_mod.linkSystemLibrary("ole32", .{});
|
||||
|
||||
@@ -126,7 +126,7 @@ test "toolbar and status-item commands navigate and reload the webview" {
|
||||
try testing.expectEqual(@as(usize, 1), harness.null_platform.trayCreateCount());
|
||||
try testing.expectEqualStrings("NS", harness.null_platform.lastTrayTitle());
|
||||
try testing.expectEqual(@as(usize, main.status_items.len), harness.null_platform.trayItems().len);
|
||||
try harness.runtime.dispatchPlatformEvent(app_state.app(), .{ .tray_action = 3 });
|
||||
try harness.runtime.dispatchPlatformEvent(app_state.app(), .{ .tray_action = .{ .item_id = 3 } });
|
||||
try testing.expectEqual(@as(u32, 1), app_state.model.reload_count);
|
||||
try testing.expectEqualStrings(main.docs_url, (try previewWebView(harness)).url);
|
||||
try testing.expectEqual(navigations_after_install + 2, harness.null_platform.webview_navigate_count);
|
||||
|
||||
@@ -224,9 +224,12 @@ fn linkPlatform(b: *std.Build, target: std.Build.ResolvedTarget, app_mod: *std.B
|
||||
if (b.sysroot) |sysroot| {
|
||||
app_mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "System/Library/Frameworks" }) });
|
||||
}
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/capture_info_plist.c"), .flags = &.{} });
|
||||
app_mod.linkFramework("AppKit", .{});
|
||||
// The audio playback service (the AppKit host's single AVPlayer).
|
||||
app_mod.linkFramework("AVFoundation", .{});
|
||||
app_mod.linkFramework("CoreMedia", .{});
|
||||
app_mod.linkFramework("ScreenCaptureKit", .{ .weak = true });
|
||||
// CVPixelBuffer for the video frame path (the AppKit host's
|
||||
// AVPlayerItemVideoOutput frames are real CoreVideo symbols).
|
||||
app_mod.linkFramework("CoreVideo", .{});
|
||||
@@ -298,10 +301,13 @@ fn linkPlatform(b: *std.Build, target: std.Build.ResolvedTarget, app_mod: *std.B
|
||||
app_mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
|
||||
},
|
||||
}
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/windows/gpu_surface_renderer.cpp"), .flags = &.{"-std=c++17"} });
|
||||
app_mod.linkSystemLibrary("c", .{});
|
||||
app_mod.linkSystemLibrary("c++", .{});
|
||||
app_mod.linkSystemLibrary("user32", .{});
|
||||
app_mod.linkSystemLibrary("gdi32", .{});
|
||||
app_mod.linkSystemLibrary("d2d1", .{});
|
||||
app_mod.linkSystemLibrary("dwrite", .{});
|
||||
app_mod.linkSystemLibrary("imm32", .{});
|
||||
app_mod.linkSystemLibrary("comctl32", .{});
|
||||
app_mod.linkSystemLibrary("ole32", .{});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Native SDK Chatbot example
|
||||
|
||||
A streaming chat client for [Vercel AI Gateway](https://vercel.com/docs/ai-gateway), authored entirely in **TypeScript + Native markup**. Zero Zig: the logic tier is the app-core subset under `src/`, compiled to native code at build time; `src/app.native` is the whole view tier and `app.zon` the manifest. The build detects `src/core.ts` in the tree and stages the wiring itself; no JS runtime ships in the binary.
|
||||
|
||||
This is the reference answer to "can a TypeScript core stream an AI API?": the network surface is one line-routed `Cmd.fetch` to `https://ai-gateway.vercel.sh/v1/chat/completions`, with `stream: true`, `Accept: text/event-stream`, and a real `Authorization: Bearer <key>` header built at runtime. Each SSE `data:` line is an ordinary Msg; `choices[0].delta.content` is appended to committed Model state and repaints the assistant response immediately. The JSON/SSE wire format is pure byte math in the subset, and because the whole exchange is effect data, a recorded conversation **replays byte-identically with zero network and zero env reads** — including every partial reply.
|
||||
|
||||
The core is two modules plus two SDK libraries:
|
||||
|
||||
- `src/core.ts` — the entry module: Model (completed history, the in-progress assistant reply, the composer, the request phase, and launch configuration), Msg, update, the env channel, and every exported binding helper.
|
||||
- `src/api.ts` — the Gateway chat-completions wire format over bytes: request encoding (JSON escaping included) and SSE parsing (`choices[0].delta.content`, `[DONE]`, and `error.message`).
|
||||
- `@native-sdk/core/text` — the SDK's byte-splice text engine, compiled in for the composer's caret/selection/IME fidelity.
|
||||
- `@native-sdk/core/events` — canonical scroll and window-chrome event shapes; the latter drives the text-free, draggable hidden titlebar and keeps its New chat button clear of the traffic lights.
|
||||
|
||||
```sh
|
||||
AI_GATEWAY_API_KEY="<your Vercel AI Gateway key>" \
|
||||
native dev # run the real app
|
||||
native dev --core --script dev-script.ndjson # the core-logic loop under node - no renderer, no network
|
||||
native check # subset-check the core's import graph + markup + app.zon
|
||||
```
|
||||
|
||||
The example defaults to `openai/gpt-5.6-luna`. The model selector inside the prompt group offers GPT-5.6 Luna, GPT-5.6 Terra, and GPT-5.6 Sol, backed by `openai/gpt-5.6-luna`, `openai/gpt-5.6-terra`, and `openai/gpt-5.6-sol`; the next request uses the selected model. To start with another Gateway model, add `NATIVE_SDK_CHAT_MODEL="<creator/model>"` to the `native dev` command.
|
||||
|
||||
The end-to-end proof battery lives in the SDK repo (`tests/ts-core/ai_chat_e2e_tests.zig`, run by `zig build test-ts-core-e2e`): it drives this example's real core and shipping markup headlessly through the teaching state (zero fetches without configuration), a scripted conversation with the Gateway request bytes pinned, partial text asserted before the terminal, the in-flight guard, every failure shape, and record→replay with the launch variables unset and changed.
|
||||
|
||||
## Configuration: the env channel
|
||||
|
||||
The API key and optional model override arrive through the core's `envMsgs` channel — one journaled Msg per variable present at install. The core never reads the environment (that would break determinism). The Vercel AI Gateway Chat Completions endpoint and the `openai/gpt-5.6-luna` default are intentionally fixed and reviewable in `src/core.ts`; **no key exists anywhere in this tree**. Until the key is present and non-empty, the app shows a setup panel and issues zero requests.
|
||||
|
||||
- **`NATIVE_SDK_CHAT_MODEL`** *(optional)* — overrides the `openai/gpt-5.6-luna` default with another Gateway `creator/model` id from the [model catalog](https://vercel.com/ai-gateway/models). An empty value leaves the default in place.
|
||||
- **`AI_GATEWAY_API_KEY`** — a Vercel AI Gateway API key, sent as the standard `Authorization: Bearer <key>` header.
|
||||
|
||||
Record/replay journals these deliveries: a session recorded with the variables set replays byte-identically on a machine where they are unset or different — the recorded values feed from the journal, and replay never reads the environment.
|
||||
|
||||
## Where this example is honest about v1 boundaries
|
||||
|
||||
Every line below is a decided posture, listed on purpose:
|
||||
|
||||
- **Replies really stream.** `Cmd.fetch` routes every complete SSE line through `chat_line`; the core decodes `choices[0].delta.content`, appends it to `pendingReply`, and the markup displays that field while the request is live. `[DONE]` plus a 2xx terminal commits the completed assistant turn.
|
||||
- **A failed request keeps the conversation.** Every failure shape — a non-2xx status (the Gateway's own `error.message` surfaces when a response line carries one), a 2xx stream missing `[DONE]`, a textless completion, or a transport failure — lands in one failed state. Partial assistant text is discarded; the unanswered user turn stays, and Retry re-sends the same history.
|
||||
- **One request in flight, by construction.** `phase === "sending"` guards every send path in update, and the `"chat"` effect key would reject a duplicate at the engine even if update misbehaved. While a reply streams, the Send action becomes Stop: it cancels the keyed request immediately and keeps any text already received as the assistant's stopped response. A send blocked by the guard loses nothing — the draft survives.
|
||||
- **Long conversations retain full visible history and prune provider context.** Before each send, the encoder measures the exact JSON-escaped size and sends the newest whole, user-led suffix that fits the engine's 64 KiB fetch-body bound. Older turns remain in the UI and the session Model; they simply stop riding the API request. If the fixed request fields and newest prompt alone cannot fit, the app enters its failed state locally instead of issuing a rejected fetch.
|
||||
- **One streamed reply is capped at 256 KiB.** Crossing the cap cancels the live request, reports the failure, and keeps the unanswered user turn. Individual protocol lines use a 64 KiB bound.
|
||||
- **The conversation is not persisted.** The Model is the session; an app that wants history across launches declares the `persist` capability and issues `Cmd.persist()` after committing a turn.
|
||||
- **Desktop only.** TypeScript cores build desktop apps today.
|
||||
- **The encoder's helpers return byte arrays instead of appending to a shared buffer.** Local mutation ends at the first escape — an array passed to another function is no longer yours to mutate (the NS1051 "mutates after the array escaped" rule) — so `encodeChatRequest` assembles the request from values its helpers return, in one literal, rather than handing a parts buffer around between pushes. The bounded wrapper measures those values first and never assembles an oversized complete body.
|
||||
@@ -1,12 +1,12 @@
|
||||
.{
|
||||
.id = "dev.native_sdk.ai_chat_ts",
|
||||
.name = "ai-chat-ts",
|
||||
.display_name = "AI Chat TS",
|
||||
.description = "A chat client for an OpenAI-compatible endpoint, authored entirely in TypeScript + Native markup.",
|
||||
.id = "dev.native_sdk.chatbot",
|
||||
.name = "chatbot",
|
||||
.display_name = "Chatbot",
|
||||
.description = "A streaming Vercel AI Gateway chat client, authored entirely in TypeScript + Native markup.",
|
||||
.version = "0.1.0",
|
||||
.platforms = .{"macos"},
|
||||
// The network permission covers the one effect the app performs:
|
||||
// the buffered `Cmd.fetch` exchange with the configured
|
||||
// the streaming `Cmd.fetch` exchange with Vercel AI Gateway's fixed
|
||||
// chat-completions endpoint. Nothing else leaves the process.
|
||||
.permissions = .{ "view", "network" },
|
||||
.capabilities = .{ "native_views", "gpu_surfaces" },
|
||||
@@ -16,13 +16,14 @@
|
||||
.windows = .{
|
||||
.{
|
||||
.label = "main",
|
||||
.title = "AI Chat TS",
|
||||
.title = "Chatbot",
|
||||
.width = 760,
|
||||
.height = 640,
|
||||
.min_width = 560,
|
||||
.min_height = 420,
|
||||
.restore_state = false,
|
||||
.restore_policy = "center_on_primary",
|
||||
.titlebar = "hidden_inset_tall",
|
||||
.views = .{
|
||||
.{ .label = "chat-canvas", .kind = "gpu_surface", .fill = true, .role = "Chat canvas", .accessibility_label = "AI chat conversation", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
|
||||
},
|
||||
@@ -0,0 +1,43 @@
|
||||
# The chat client's core-logic loop, headless: replay with
|
||||
# native dev --core --script dev-script.ndjson
|
||||
# Msgs dispatch into update; each Gateway SSE line is an ordinary Msg, so
|
||||
# deltas and terminals are fed back by hand exactly as the transcript's
|
||||
# `cmd fetch ...` lines invite — the same loop the native app runs, with
|
||||
# you standing in for the network.
|
||||
|
||||
# The API key arrives through the env channel as an ordinary Msg (under
|
||||
# the real app the generated wiring dispatches it from the environment
|
||||
# at install). The endpoint and openai/gpt-5.6-luna default are fixed in
|
||||
# core.ts; nothing dials out under the core host.
|
||||
{"kind":"key_set","value":{"$bytes":"example-gateway-key"}}
|
||||
|
||||
# Type a message (the composer runs the SDK byte-splice text engine) and
|
||||
# send. The transcript shows the streaming fetch command whole: POST, the
|
||||
# Gateway endpoint, SSE accept and runtime-built authorization headers,
|
||||
# and the JSON body — system prompt first, then history and stream:true.
|
||||
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"Say hi in two words"}}}
|
||||
{"kind":"send"}
|
||||
|
||||
# Two Gateway deltas grow the pending assistant response immediately. The
|
||||
# explicit [DONE] marker and HTTP terminal then commit the whole turn.
|
||||
{"kind":"chat_line","line":{"$bytes":"data: {\"choices\":[{\"delta\":{\"content\":\"Hi\\n\"}}]}"}}
|
||||
{"kind":"chat_line","line":{"$bytes":"data: {\"choices\":[{\"delta\":{\"content\":\"there!\"}}]}"}}
|
||||
{"kind":"chat_line","line":{"$bytes":"data: [DONE]"}}
|
||||
{"kind":"chat_done","status":200}
|
||||
|
||||
# A second turn grows the history: watch the request body carry both
|
||||
# earlier turns before the new question.
|
||||
{"kind":"draft_edit","edit":{"kind":"insert_text","text":{"$bytes":"And a follow-up?"}}}
|
||||
{"kind":"send"}
|
||||
|
||||
# This time the Gateway fails with its own JSON error line — the failed
|
||||
# state keeps the history and surfaces error.message as the reason.
|
||||
{"kind":"chat_line","line":{"$bytes":"{\"error\":{\"message\":\"model overloaded\",\"type\":\"server_error\"}}"}}
|
||||
{"kind":"chat_done","status":500}
|
||||
|
||||
# Retry re-sends the SAME conversation (no new turn); a success resolves
|
||||
# it into the fourth turn.
|
||||
{"kind":"retry"}
|
||||
{"kind":"chat_line","line":{"$bytes":"data: {\"choices\":[{\"delta\":{\"content\":\"Certainly.\"}}]}"}}
|
||||
{"kind":"chat_line","line":{"$bytes":"data: [DONE]"}}
|
||||
{"kind":"chat_done","status":200}
|
||||
@@ -1,12 +1,10 @@
|
||||
// ai-chat-ts api module: the OpenAI-compatible chat-completions wire
|
||||
// format in pure subset TypeScript over bytes — request encoding on the
|
||||
// way out, response parsing on the way back. No JSON runtime exists in a
|
||||
// core (the binary carries no JS engine), and none is needed: the request
|
||||
// is a byte concatenation with one escape routine, and the response walk
|
||||
// reads exactly the two fields the app uses (`choices[0].message.content`
|
||||
// on success, `error.message` on failure) and refuses everything
|
||||
// malformed with `null` — a body that does not parse is a failed request,
|
||||
// never a half-parsed conversation.
|
||||
// Chatbot API module: the Vercel AI Gateway's OpenAI-compatible
|
||||
// chat-completions wire format in pure subset TypeScript over bytes —
|
||||
// request encoding on the way out, SSE parsing on the way back. No JSON
|
||||
// runtime exists in a core (the binary carries no JS engine), and none is
|
||||
// needed: the request is byte concatenation with one escape routine, and
|
||||
// each response-line walk reads exactly the fields the app uses
|
||||
// (`choices[0].delta.content` or `error.message`).
|
||||
//
|
||||
// Everything here is deterministic byte math, which is what makes the
|
||||
// announcement trick work: the exact request bytes are pinned in the e2e
|
||||
@@ -27,6 +25,15 @@ export interface Turn {
|
||||
readonly text: Bytes;
|
||||
}
|
||||
|
||||
/// The only four meanings the chat UI needs from one response line.
|
||||
/// Role-only and finish-reason chunks are valid but carry no visible
|
||||
/// text, so they are deliberately `ignore` rather than parse failures.
|
||||
export type ChatStreamEvent =
|
||||
| { readonly kind: "ignore" }
|
||||
| { readonly kind: "delta"; readonly text: Bytes }
|
||||
| { readonly kind: "done" }
|
||||
| { readonly kind: "error"; readonly message: Bytes };
|
||||
|
||||
/// How deep a response's nesting may go before the scanner refuses it —
|
||||
/// a bound on recursion, not on honest responses (a chat-completions
|
||||
/// body nests four levels).
|
||||
@@ -34,9 +41,17 @@ const MAX_JSON_DEPTH = 64;
|
||||
|
||||
// ------------------------------------------------------------- request
|
||||
|
||||
const REQUEST_MODEL_OPEN = asciiBytes('{"model":');
|
||||
const REQUEST_MESSAGES_OPEN = asciiBytes(',"messages":[{"role":"system","content":');
|
||||
const MESSAGE_CLOSE = asciiBytes("}");
|
||||
const USER_TURN_OPEN = asciiBytes(',{"role":"user","content":');
|
||||
const ASSISTANT_TURN_OPEN = asciiBytes(',{"role":"assistant","content":');
|
||||
const REQUEST_CLOSE = asciiBytes('],"stream":true}');
|
||||
|
||||
/// The chat-completions request body:
|
||||
/// `{"model":…,"messages":[{"role":"system","content":…},…]}` with the
|
||||
/// system prompt first and every conversation turn after it, in order.
|
||||
/// `{"model":…,"messages":[{"role":"system","content":…},…],"stream":true}`
|
||||
/// with the system prompt first and every conversation turn after it, in
|
||||
/// order. `stream: true` selects the Gateway's SSE response.
|
||||
/// The caller supplies the model name from the launch environment; the
|
||||
/// turns are the Model's history including the just-appended user turn.
|
||||
/// Helpers RETURN their bytes and this one builder assembles them in a
|
||||
@@ -45,25 +60,68 @@ const MAX_JSON_DEPTH = 64;
|
||||
/// each turn arrives pre-concatenated from `encodeTurn` instead.
|
||||
export function encodeChatRequest(modelName: Bytes, systemPrompt: Bytes, turns: readonly Turn[]): Bytes {
|
||||
return concatAll([
|
||||
asciiBytes('{"model":'),
|
||||
REQUEST_MODEL_OPEN,
|
||||
jsonString(modelName),
|
||||
asciiBytes(',"messages":[{"role":"system","content":'),
|
||||
REQUEST_MESSAGES_OPEN,
|
||||
jsonString(systemPrompt),
|
||||
asciiBytes("}"),
|
||||
MESSAGE_CLOSE,
|
||||
...turns.map((turn) => encodeTurn(turn)),
|
||||
asciiBytes("]}"),
|
||||
REQUEST_CLOSE,
|
||||
]);
|
||||
}
|
||||
|
||||
/// Encode the newest contiguous, user-led suffix that fits `maxBytes`.
|
||||
/// The Model keeps every turn; only the provider context is pruned. The
|
||||
/// exact JSON-escaped size is measured first, so this never constructs a
|
||||
/// complete oversized request just to discover that the engine rejects it.
|
||||
/// An empty result means even the fixed envelope and newest user turn do
|
||||
/// not fit, which lets the caller fail locally instead of issuing a fetch.
|
||||
export function encodeChatRequestWithinLimit(
|
||||
modelName: Bytes,
|
||||
systemPrompt: Bytes,
|
||||
turns: readonly Turn[],
|
||||
maxBytes: number,
|
||||
): Bytes {
|
||||
let total =
|
||||
REQUEST_MODEL_OPEN.length +
|
||||
jsonStringLength(modelName) +
|
||||
REQUEST_MESSAGES_OPEN.length +
|
||||
jsonStringLength(systemPrompt) +
|
||||
MESSAGE_CLOSE.length +
|
||||
REQUEST_CLOSE.length;
|
||||
if (total > maxBytes) return new Uint8Array(0);
|
||||
|
||||
let start = turns.length;
|
||||
while (start > 0) {
|
||||
const candidate = start - 1;
|
||||
const turnLength = encodedTurnLength(turns[candidate]);
|
||||
if (total + turnLength > maxBytes) break;
|
||||
total += turnLength;
|
||||
start = candidate;
|
||||
}
|
||||
|
||||
// A request with history must include its newest user turn. If that
|
||||
// turn did not fit, there is no useful smaller suffix to send.
|
||||
if (turns.length > 0 && start === turns.length) return new Uint8Array(0);
|
||||
|
||||
// Truncation can land just before an assistant response. Drop that
|
||||
// orphaned response so the retained context always begins with a user.
|
||||
while (start < turns.length && turns[start].role !== "user") start += 1;
|
||||
if (turns.length > 0 && start === turns.length) return new Uint8Array(0);
|
||||
return encodeChatRequest(modelName, systemPrompt, turns.slice(start));
|
||||
}
|
||||
|
||||
/// One conversation turn as its complete message-object bytes:
|
||||
/// `,{"role":…,"content":…}` — comma included, since every turn follows
|
||||
/// the system message.
|
||||
function encodeTurn(turn: Turn): Bytes {
|
||||
const open =
|
||||
turn.role === "user"
|
||||
? asciiBytes(',{"role":"user","content":')
|
||||
: asciiBytes(',{"role":"assistant","content":');
|
||||
return concatAll([open, jsonString(turn.text), asciiBytes("}")]);
|
||||
const open = turn.role === "user" ? USER_TURN_OPEN : ASSISTANT_TURN_OPEN;
|
||||
return concatAll([open, jsonString(turn.text), MESSAGE_CLOSE]);
|
||||
}
|
||||
|
||||
function encodedTurnLength(turn: Turn): number {
|
||||
const openLength = turn.role === "user" ? USER_TURN_OPEN.length : ASSISTANT_TURN_OPEN.length;
|
||||
return openLength + jsonStringLength(turn.text) + MESSAGE_CLOSE.length;
|
||||
}
|
||||
|
||||
/// A JSON string literal (quotes included) from UTF-8 text bytes. Two
|
||||
@@ -72,17 +130,7 @@ function encodeTurn(turn: Turn): Bytes {
|
||||
/// helper would have escaped and become immutable), so every escape is
|
||||
/// written inline. Non-ASCII UTF-8 bytes pass through raw (valid JSON).
|
||||
export function jsonString(text: Bytes): Bytes {
|
||||
let len = 2;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const b = text[i];
|
||||
if (b === 0x22 || b === 0x5c || b === 0x08 || b === 0x09 || b === 0x0a || b === 0x0c || b === 0x0d) {
|
||||
len += 2;
|
||||
} else if (b < 0x20) {
|
||||
len += 6; // \u00XX
|
||||
} else {
|
||||
len += 1;
|
||||
}
|
||||
}
|
||||
const len = jsonStringLength(text);
|
||||
const out = new Uint8Array(len);
|
||||
out[0] = 0x22;
|
||||
let at = 1;
|
||||
@@ -113,6 +161,21 @@ export function jsonString(text: Bytes): Bytes {
|
||||
return out;
|
||||
}
|
||||
|
||||
function jsonStringLength(text: Bytes): number {
|
||||
let len = 2;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const b = text[i];
|
||||
if (b === 0x22 || b === 0x5c || b === 0x08 || b === 0x09 || b === 0x0a || b === 0x0c || b === 0x0d) {
|
||||
len += 2;
|
||||
} else if (b < 0x20) {
|
||||
len += 6; // \u00XX
|
||||
} else {
|
||||
len += 1;
|
||||
}
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
/// The letter of a two-byte JSON escape: \b \t \n \f \r.
|
||||
function escapeLetter(b: number): number {
|
||||
if (b === 0x08) return 0x62;
|
||||
@@ -149,11 +212,42 @@ export function bearerToken(apiKey: Bytes): Bytes {
|
||||
|
||||
// ------------------------------------------------------------ response
|
||||
|
||||
/// `choices[0].message.content` from a chat-completions success body, or
|
||||
/// null when the body is not that shape (malformed JSON, empty choices,
|
||||
/// a non-string content) — the caller turns null into the failed state.
|
||||
export function parseChatContent(body: Bytes): Bytes | null {
|
||||
let at = skipWs(body, 0);
|
||||
/// Parse one complete response line from the Gateway. Successful
|
||||
/// chat-completions streams are SSE (`data: <json>` and `data: [DONE]`).
|
||||
/// A non-2xx response can instead arrive as a plain one-line JSON error,
|
||||
/// so the error parser also checks the raw line.
|
||||
export function parseChatStreamLine(line: Bytes): ChatStreamEvent {
|
||||
const payload = sseData(line);
|
||||
if (payload === null) {
|
||||
const rawError = parseErrorMessage(line);
|
||||
return rawError === null ? { kind: "ignore" } : { kind: "error", message: rawError };
|
||||
}
|
||||
if (bytesEqual(payload, asciiBytes("[DONE]"))) return { kind: "done" };
|
||||
const error = parseErrorMessage(payload);
|
||||
if (error !== null) return { kind: "error", message: error };
|
||||
const delta = parseChatDelta(payload);
|
||||
return delta === null ? { kind: "ignore" } : { kind: "delta", text: delta };
|
||||
}
|
||||
|
||||
/// The bytes after an SSE `data:` field, with the optional one space and
|
||||
/// a trailing CR removed. Other SSE fields and blank separator lines are
|
||||
/// not chat payloads.
|
||||
function sseData(line: Bytes): Bytes | null {
|
||||
if (line.length < 5) return null;
|
||||
if (line[0] !== 0x64 || line[1] !== 0x61 || line[2] !== 0x74 || line[3] !== 0x61 || line[4] !== 0x3a) {
|
||||
return null;
|
||||
}
|
||||
let start = 5;
|
||||
if (start < line.length && line[start] === 0x20) start += 1;
|
||||
let end = line.length;
|
||||
if (end > start && line[end - 1] === 0x0d) end -= 1;
|
||||
return line.slice(start, end);
|
||||
}
|
||||
|
||||
/// `choices[0].delta.content` from one OpenAI-compatible stream event,
|
||||
/// or null when this chunk carries no visible content.
|
||||
function parseChatDelta(body: Bytes): Bytes | null {
|
||||
const at = skipWs(body, 0);
|
||||
if (at >= body.length || body[at] !== 0x7b) return null; // {
|
||||
const choicesAt = memberValue(body, at, asciiBytes("choices"));
|
||||
if (choicesAt === -1) return null;
|
||||
@@ -162,9 +256,9 @@ export function parseChatContent(body: Bytes): Bytes | null {
|
||||
cursor = skipWs(body, cursor + 1);
|
||||
if (cursor >= body.length || body[cursor] === 0x5d) return null; // empty choices
|
||||
if (body[cursor] !== 0x7b) return null;
|
||||
const messageAt = memberValue(body, cursor, asciiBytes("message"));
|
||||
if (messageAt === -1) return null;
|
||||
cursor = skipWs(body, messageAt);
|
||||
const deltaAt = memberValue(body, cursor, asciiBytes("delta"));
|
||||
if (deltaAt === -1) return null;
|
||||
cursor = skipWs(body, deltaAt);
|
||||
if (cursor >= body.length || body[cursor] !== 0x7b) return null;
|
||||
const contentAt = memberValue(body, cursor, asciiBytes("content"));
|
||||
if (contentAt === -1) return null;
|
||||
@@ -230,6 +324,14 @@ function bytesEqualRange(b: Bytes, start: number, end: number, key: Bytes): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
function bytesEqual(left: Bytes, right: Bytes): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
if (left[i] !== right[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The index of a string's closing quote (escape-aware, undecoded), with
|
||||
/// `at` on the opening quote — or -1 when the string never closes.
|
||||
function rawStringEnd(b: Bytes, at: number): number {
|
||||
@@ -0,0 +1,142 @@
|
||||
<!-- The chat client's whole view tier: one markup file over the TS core's
|
||||
model, bound by the names core.ts wrote (fields and exported helpers
|
||||
bind verbatim). The custom hidden titlebar carries only a New chat
|
||||
icon button, and its drag region stays clear of the traffic lights
|
||||
through core.ts's chromeMsg channel. The conversation is a
|
||||
controlled scroll of right-aligned user bubbles and full-width
|
||||
assistant text that grows as SSE deltas arrive, with honest sending
|
||||
and failed rows, and the composer
|
||||
is an input group over the core's byte-splice engine — Enter
|
||||
and the arrow button dispatch the same `send` arm, while that action
|
||||
becomes an immediate stream-cancelling Stop button during a reply. Until the Gateway API key
|
||||
arrives through the env channel, the teaching panel
|
||||
explains the setup and the app issues zero requests. -->
|
||||
<column background="background">
|
||||
<!-- The header IS the titlebar (tall hidden-inset chrome), matching the
|
||||
Kanban example. There is deliberately no visible title text. -->
|
||||
<row height="{headerHeight}" padding="12" cross="center" window-drag="true" label="Chat header">
|
||||
<spacer width="{chromeLeading}" />
|
||||
<spacer grow="1" />
|
||||
<button size="icon" variant="ghost" icon="plus" on-press="clear" label="New chat"></button>
|
||||
</row>
|
||||
<separator />
|
||||
<!-- The titlebar stays edge-to-edge. Everything below it fills narrow
|
||||
windows and centers at a readable 960pt ceiling in wide ones. -->
|
||||
<row grow="1" main="center" label="Chat body">
|
||||
<column grow="1" max-width="960" label="Chat content">
|
||||
<if test="{unconfigured}">
|
||||
<!-- The teaching state: no request ever leaves an unconfigured app
|
||||
— the panel names exactly what is missing. -->
|
||||
<column grow="1" padding="24" main="center" cross="center" label="Setup">
|
||||
<panel padding="24" background="surface" radius="lg" width="520" label="Connect a model">
|
||||
<column gap="12">
|
||||
<text><span weight="bold">Connect a model</span></text>
|
||||
<text size="sm" foreground="text_muted">This app streams OpenAI-compatible chat completions from Vercel AI Gateway. Set AI_GATEWAY_API_KEY and relaunch — the core receives it once, at install, through the env channel.</text>
|
||||
<row gap="8" cross="center">
|
||||
<text size="sm" grow="1">AI_GATEWAY_API_KEY</text>
|
||||
<if test="{keyMissing}">
|
||||
<text size="sm" foreground="destructive">missing</text>
|
||||
</if>
|
||||
<else>
|
||||
<text size="sm" foreground="success">set</text>
|
||||
</else>
|
||||
</row>
|
||||
<text size="sm" foreground="text_muted">The default model is openai/gpt-5.6-luna. Once connected, use the prompt's model selector for Luna, Terra, or Sol, or set NATIVE_SDK_CHAT_MODEL to another creator/model id from the Gateway catalog.</text>
|
||||
</column>
|
||||
</panel>
|
||||
</column>
|
||||
</if>
|
||||
<else>
|
||||
<if test="{emptyConversation}">
|
||||
<column grow="1" padding="24" gap="12" main="center" cross="center" label="Empty conversation">
|
||||
<text size="display" text-alignment="center"><span weight="bold">What can I help with?</span></text>
|
||||
<text size="lg" foreground="text_muted" text-alignment="center">Ask a question, write code, or explore ideas.</text>
|
||||
</column>
|
||||
</if>
|
||||
<else>
|
||||
<scroll grow="1" label="Conversation" value="{chatScrollTop}" on-scroll="chat_scrolled">
|
||||
<!-- Bottom breathing room belongs to the scrolling history,
|
||||
so it is present only at the tail instead of becoming a
|
||||
permanent gutter above the prompt. -->
|
||||
<column padding="24" label="Conversation history">
|
||||
<column gap="10" label="Conversation entries">
|
||||
<for each="turnRows" as="t" key="id">
|
||||
<if test="{t.user}">
|
||||
<row key="{t.id}" gap="8" label="You said">
|
||||
<spacer grow="1" min-width="64" />
|
||||
<bubble variant="primary">
|
||||
<text wrap="true">{t.text}</text>
|
||||
</bubble>
|
||||
</row>
|
||||
</if>
|
||||
<else>
|
||||
<row key="{t.id}" gap="8" label="The model said">
|
||||
<text wrap="true" grow="1">{t.text}</text>
|
||||
</row>
|
||||
</else>
|
||||
</for>
|
||||
<if test="{sending}">
|
||||
<row gap="8" label="Reply pending">
|
||||
<if test="{waitingForFirstToken}">
|
||||
<text grow="1" foreground="text_muted">…</text>
|
||||
</if>
|
||||
<else>
|
||||
<text wrap="true" grow="1">{pendingReplyLabel}</text>
|
||||
</else>
|
||||
</row>
|
||||
</if>
|
||||
<if test="{failed}">
|
||||
<panel padding="12" background="surface" radius="lg" label="Request failed">
|
||||
<row gap="10" cross="center">
|
||||
<icon name="alert" width="14" height="14" foreground="destructive" />
|
||||
<column gap="2" grow="1">
|
||||
<text size="sm" foreground="destructive">Request failed</text>
|
||||
<text size="sm" foreground="text_muted">{failReasonLabel}</text>
|
||||
</column>
|
||||
<button size="sm" variant="ghost" icon="refresh-cw" on-press="retry" label="Retry request">Retry</button>
|
||||
</row>
|
||||
</panel>
|
||||
</if>
|
||||
</column>
|
||||
<!-- A real flow child extends the scrollable content beyond
|
||||
the last wrapped line; container padding alone can stay
|
||||
pinned to the viewport when descendants overflow it. -->
|
||||
<spacer height="24" label="Conversation tail padding" />
|
||||
</column>
|
||||
</scroll>
|
||||
</else>
|
||||
<!-- No top inset: scrolling content meets the prompt border.
|
||||
Side and bottom insets remain as explicit siblings. -->
|
||||
<column label="Composer">
|
||||
<row>
|
||||
<spacer width="12" />
|
||||
<input-group grow="1" height="96" label="Prompt composer">
|
||||
<textarea text="{draftText}" placeholder="Message the model…" autofocus="{promptAutofocus}" submit-on-enter="true" on-input="draft_edit" on-submit="send" label="Message" />
|
||||
<input-group-actions>
|
||||
<stack width="148">
|
||||
<select size="sm" width="148" on-press="toggle_model_picker" label="Model selector">{modelNameLabel}</select>
|
||||
<if test="{modelPickerOpen}">
|
||||
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_model_picker" label="Models">
|
||||
<menu-item on-press="pick_model_luna" selected="{modelIsLuna}">GPT-5.6 Luna</menu-item>
|
||||
<menu-item on-press="pick_model_terra" selected="{modelIsTerra}">GPT-5.6 Terra</menu-item>
|
||||
<menu-item on-press="pick_model_sol" selected="{modelIsSol}">GPT-5.6 Sol</menu-item>
|
||||
</dropdown-menu>
|
||||
</if>
|
||||
</stack>
|
||||
<spacer grow="1" />
|
||||
<if test="{sending}">
|
||||
<button size="icon" variant="primary" icon="x" on-press="stop" label="Stop generating"></button>
|
||||
</if>
|
||||
<else>
|
||||
<button size="icon" variant="primary" icon="arrow-up" disabled="{sendDisabled}" on-press="send" label="Send message"></button>
|
||||
</else>
|
||||
</input-group-actions>
|
||||
</input-group>
|
||||
<spacer width="12" />
|
||||
</row>
|
||||
<spacer height="12" />
|
||||
</column>
|
||||
</else>
|
||||
</column>
|
||||
</row>
|
||||
</column>
|
||||
@@ -0,0 +1,678 @@
|
||||
// Chatbot core: a streaming chat client for the Vercel AI Gateway's
|
||||
// OpenAI-compatible chat-completions endpoint, authored entirely in the
|
||||
// TypeScript app-core subset. Zero Zig in this tree: the build transpiles
|
||||
// this module and src/api.ts,
|
||||
// src/app.native is the whole view, app.zon the manifest.
|
||||
//
|
||||
// The core is two modules plus two SDK libraries, all under src/:
|
||||
//
|
||||
// core.ts (this file) Model, Msg, update, the wiring channels, and
|
||||
// every exported binding helper — the entry module is the
|
||||
// app's public face (markup and node both see its exports)
|
||||
// api.ts the chat-completions wire format in pure bytes: request
|
||||
// encoding and SSE parsing (choices[0].delta.content and
|
||||
// error.message — exactly the fields the app reads)
|
||||
// @native-sdk/core/text the SDK's byte-splice text engine, transpiled
|
||||
// in for the composer's caret/selection/IME fidelity
|
||||
// @native-sdk/core/events the scroll and hidden-titlebar geometry
|
||||
// records used by the markup's controlled host channels
|
||||
//
|
||||
// The whole network surface is ONE streaming effect: `Cmd.fetch` on the
|
||||
// "chat" key. Every Gateway SSE line is a Msg, so visible assistant text
|
||||
// grows in committed Model state as tokens arrive. The in-flight
|
||||
// discipline is model-first: `phase === "sending"` blocks every re-send
|
||||
// in update, and the engine rejects a duplicate live streaming key.
|
||||
//
|
||||
// The Gateway endpoint and three composer model choices are fixed and
|
||||
// reviewable below. `NATIVE_SDK_CHAT_MODEL` can override the initial
|
||||
// choice, while
|
||||
// `AI_GATEWAY_API_KEY` arrives through `envMsgs`; both deliveries are
|
||||
// journaled Msgs at install. The core never reads the environment
|
||||
// (NS1005), which is why a recorded conversation replays byte-identically
|
||||
// without either value.
|
||||
|
||||
import { Cmd, asciiBytes, type EnvMsg } from "@native-sdk/core";
|
||||
import {
|
||||
applyTextInputEvent,
|
||||
clampedInsertEvent,
|
||||
type TextEditState,
|
||||
type TextInputEvent,
|
||||
} from "@native-sdk/core/text";
|
||||
// The SDK-provided scroll-state record (the shape markup's on-scroll
|
||||
// matches structurally - imported, so no in-file mirror can drift).
|
||||
import {
|
||||
type ChromeButtons,
|
||||
type ChromeInsets,
|
||||
type ScrollState,
|
||||
} from "@native-sdk/core/events";
|
||||
import {
|
||||
bearerToken,
|
||||
concatAll,
|
||||
encodeChatRequestWithinLimit,
|
||||
parseChatStreamLine,
|
||||
type Bytes,
|
||||
type Turn,
|
||||
} from "./api.ts";
|
||||
|
||||
/// Vercel AI Gateway's OpenAI-compatible Chat Completions REST endpoint.
|
||||
/// Keeping it fixed makes this example specifically a Gateway client;
|
||||
/// only the optional creator/model override and API key are launch
|
||||
/// configuration.
|
||||
const AI_GATEWAY_ENDPOINT = asciiBytes("https://ai-gateway.vercel.sh/v1/chat/completions");
|
||||
|
||||
/// The example works with only a Gateway API key. Luna is the initial
|
||||
/// composer selection; Terra and Sol are the other built-in choices.
|
||||
const DEFAULT_MODEL = asciiBytes("openai/gpt-5.6-luna");
|
||||
const MODEL_TERRA = asciiBytes("openai/gpt-5.6-terra");
|
||||
const MODEL_SOL = asciiBytes("openai/gpt-5.6-sol");
|
||||
const MODEL_LABEL_LUNA = asciiBytes("GPT-5.6 Luna");
|
||||
const MODEL_LABEL_TERRA = asciiBytes("GPT-5.6 Terra");
|
||||
const MODEL_LABEL_SOL = asciiBytes("GPT-5.6 Sol");
|
||||
|
||||
/// The conversation's standing instruction, first in every request's
|
||||
/// message list. One constant, versioned with the app — not model state,
|
||||
/// so replay and the request pins never depend on it drifting.
|
||||
const SYSTEM_PROMPT = asciiBytes(
|
||||
"You are a helpful assistant inside a native desktop app. Answer concisely, in plain text.",
|
||||
);
|
||||
|
||||
/// The composer's byte capacity. Outbound encoding always retains this
|
||||
/// newest prompt and prunes older provider context to the fetch bound.
|
||||
const MAX_DRAFT = 4096;
|
||||
|
||||
/// The engine accepts at most 64 KiB of fetch body. The visible Model
|
||||
/// keeps full history; request encoding sends its newest user-led suffix.
|
||||
const MAX_REQUEST_BODY = 64 * 1024;
|
||||
const REQUEST_TOO_LARGE = asciiBytes("the request is too large");
|
||||
|
||||
/// Keep one in-progress answer no larger than the buffered fetch limit
|
||||
/// this example used before streaming. If a provider exceeds it, stop
|
||||
/// the live request and retain the unanswered user turn for Retry.
|
||||
const MAX_REPLY = 262144;
|
||||
|
||||
/// The app's own header is the tall hidden-inset titlebar. The host may
|
||||
/// report a larger top inset, so chrome_changed keeps this as a floor.
|
||||
const HEADER_NATURAL_HEIGHT = 52;
|
||||
|
||||
/// Assigning the scroll binding a value past the content clamps to the
|
||||
/// bottom. Alternating the two out-of-range values makes every token a
|
||||
/// source-side move even when no host scroll echo arrives between rebuilds.
|
||||
const SCROLL_BOTTOM = 1000000;
|
||||
|
||||
// -------------------------------------------------------------- composer
|
||||
// The fixed-capacity editor state for the message field: the SDK text
|
||||
// engine does the byte splicing; this wrapper is the app's flat committed
|
||||
// shape for it (compStart -1 = no composition). Immutable: composerApply
|
||||
// returns a new value.
|
||||
|
||||
export interface ComposerDraft {
|
||||
readonly bytes: Bytes;
|
||||
readonly anchor: number;
|
||||
readonly focus: number;
|
||||
readonly compStart: number; // -1 when no composition
|
||||
readonly compEnd: number;
|
||||
}
|
||||
|
||||
function composerInit(): ComposerDraft {
|
||||
return { bytes: new Uint8Array(0), anchor: 0, focus: 0, compStart: -1, compEnd: -1 };
|
||||
}
|
||||
|
||||
function composerState(d: ComposerDraft): TextEditState {
|
||||
return {
|
||||
text: d.bytes,
|
||||
selection: { anchor: d.anchor, focus: d.focus },
|
||||
composition: d.compStart >= 0 ? { start: d.compStart, end: d.compEnd } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function composerApply(d: ComposerDraft, event: TextInputEvent): ComposerDraft {
|
||||
const state = composerState(d);
|
||||
const next = applyTextInputEvent(state, event, MAX_DRAFT);
|
||||
if (next === null) {
|
||||
// Over-capacity: clamp an insert to the bytes that fit (refuse-whole
|
||||
// for everything else) — the runtime TextBuffer's contract.
|
||||
const clamped = clampedInsertEvent(state, event, MAX_DRAFT);
|
||||
if (clamped === null) return d;
|
||||
const nextClamped = applyTextInputEvent(state, clamped, MAX_DRAFT);
|
||||
if (nextClamped === null) return d;
|
||||
// Composition bounds land in i64-classed slots: bind them, guard
|
||||
// the range (an ordered comparison excludes NaN), and state
|
||||
// wholeness with Math.trunc at the write; -1 stays the no-composition
|
||||
// sentinel.
|
||||
const clampedStart = nextClamped.composition !== null ? nextClamped.composition.start : -1;
|
||||
const clampedEnd = nextClamped.composition !== null ? nextClamped.composition.end : -1;
|
||||
return {
|
||||
bytes: nextClamped.text,
|
||||
anchor: nextClamped.selection.anchor,
|
||||
focus: nextClamped.selection.focus,
|
||||
compStart: clampedStart >= -1 && clampedStart <= 9007199254740991 ? Math.trunc(clampedStart) : -1,
|
||||
compEnd: clampedEnd >= -1 && clampedEnd <= 9007199254740991 ? Math.trunc(clampedEnd) : -1,
|
||||
};
|
||||
}
|
||||
const nextStart = next.composition !== null ? next.composition.start : -1;
|
||||
const nextEnd = next.composition !== null ? next.composition.end : -1;
|
||||
return {
|
||||
bytes: next.text,
|
||||
anchor: next.selection.anchor,
|
||||
focus: next.selection.focus,
|
||||
compStart: nextStart >= -1 && nextStart <= 9007199254740991 ? Math.trunc(nextStart) : -1,
|
||||
compEnd: nextEnd >= -1 && nextEnd <= 9007199254740991 ? Math.trunc(nextEnd) : -1,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ model
|
||||
|
||||
export type Phase = "idle" | "sending" | "failed";
|
||||
|
||||
export interface Model {
|
||||
/// The conversation, oldest first — user and assistant turns alike.
|
||||
/// Committed state, so record→replay carries the whole conversation.
|
||||
readonly turns: readonly Turn[];
|
||||
readonly nextId: number;
|
||||
/// The request lifecycle: `sending` is the in-flight guard (every
|
||||
/// re-send path checks it), `failed` keeps the history and shows the
|
||||
/// reason until the next send.
|
||||
readonly phase: Phase;
|
||||
/// Why the last request failed: the transport reason (`timed_out`,
|
||||
/// `connect_failed`, ...), the Gateway's own error.message, or the
|
||||
/// HTTP status line — never empty in the failed phase.
|
||||
readonly failReason: Bytes;
|
||||
/// The assistant text received so far for the live request. It is
|
||||
/// rendered immediately but joins `turns` only after a clean terminal.
|
||||
readonly pendingReply: Bytes;
|
||||
/// The Gateway's explicit `data: [DONE]` marker. A clean HTTP EOF
|
||||
/// without it is treated as a truncated protocol response.
|
||||
readonly streamDone: boolean;
|
||||
readonly draft: ComposerDraft;
|
||||
/// The Gateway creator/model id and API key. The model starts at the
|
||||
/// example default and can be replaced by the optional env delivery;
|
||||
/// the app teaches setup until the key arrives.
|
||||
readonly modelName: Bytes;
|
||||
/// The prompt group's model picker is ordinary model-owned UI state. It is
|
||||
/// closed on selection and when a request starts.
|
||||
readonly modelPickerOpen: boolean;
|
||||
/// Autofocus is edge-triggered. Opening the picker lowers this bit;
|
||||
/// choosing a model raises it again so focus returns to the textarea.
|
||||
readonly promptAutofocus: boolean;
|
||||
readonly apiKey: Bytes;
|
||||
/// The conversation scroll offset, echoed from markup's `on-scroll`
|
||||
/// and pushed past the content on every new turn (the clamp lands it
|
||||
/// at the bottom) — the controlled-scroll shape.
|
||||
readonly chatScrollTop: number;
|
||||
/// Alternates the two out-of-range tail requests so every streamed
|
||||
/// delta remains a source-side scroll move even without a host echo.
|
||||
readonly scrollPulse: boolean;
|
||||
/// Hidden-titlebar geometry delivered before the first view build.
|
||||
/// chromeLeading keeps controls clear of the macOS traffic lights;
|
||||
/// headerHeight follows the host's titlebar band with a 52pt floor.
|
||||
readonly chromeLeading: number;
|
||||
readonly headerHeight: number;
|
||||
}
|
||||
|
||||
export function initialModel(): Model {
|
||||
return {
|
||||
turns: [],
|
||||
nextId: 1,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
draft: composerInit(),
|
||||
modelName: DEFAULT_MODEL,
|
||||
modelPickerOpen: false,
|
||||
promptAutofocus: true,
|
||||
apiKey: new Uint8Array(0),
|
||||
chatScrollTop: 0,
|
||||
scrollPulse: false,
|
||||
chromeLeading: 0,
|
||||
headerHeight: HEADER_NATURAL_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- msg
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "draft_edit"; readonly edit: TextInputEvent }
|
||||
/// The send gesture: the composer's Enter (markup `on-submit`) and the
|
||||
/// Send button dispatch the same arm.
|
||||
| { readonly kind: "send" }
|
||||
/// Cancel the live keyed request, keeping any assistant text that has
|
||||
/// already arrived as the stopped response.
|
||||
| { readonly kind: "stop" }
|
||||
/// Re-issue the failed request over the history as it stands (the
|
||||
/// unanswered user turn is already the last entry).
|
||||
| { readonly kind: "retry" }
|
||||
| { readonly kind: "clear" }
|
||||
| { readonly kind: "toggle_model_picker" }
|
||||
| { readonly kind: "close_model_picker" }
|
||||
| { readonly kind: "pick_model_sol" }
|
||||
| { readonly kind: "pick_model_luna" }
|
||||
| { readonly kind: "pick_model_terra" }
|
||||
/// One complete SSE/body line from the Gateway.
|
||||
| { readonly kind: "chat_line"; readonly line: Bytes }
|
||||
/// The delivered streaming response's terminal HTTP status.
|
||||
| { readonly kind: "chat_done"; readonly status: number }
|
||||
/// The transport failure — the fetch err arm's machine-readable reason.
|
||||
| { readonly kind: "chat_failed"; readonly reason: Bytes }
|
||||
| { readonly kind: "chat_scrolled"; readonly scroll: ScrollState }
|
||||
| {
|
||||
readonly kind: "chrome_changed";
|
||||
readonly insets: ChromeInsets;
|
||||
readonly buttons: ChromeButtons;
|
||||
readonly tabsProjected: boolean;
|
||||
}
|
||||
| { readonly kind: "model_set"; readonly value: Bytes }
|
||||
| { readonly kind: "key_set"; readonly value: Bytes };
|
||||
|
||||
// --------------------------------------------------- host-event channels
|
||||
|
||||
/// The launch configuration channel: each variable present at launch
|
||||
/// dispatches one journaled Msg right after boot. The URL and default
|
||||
/// model are fixed above; the model variable is an optional override,
|
||||
/// and no key exists in this tree.
|
||||
export const envMsgs: readonly EnvMsg<Msg>[] = [
|
||||
{ env: "NATIVE_SDK_CHAT_MODEL", msg: "model_set" },
|
||||
{ env: "AI_GATEWAY_API_KEY", msg: "key_set" },
|
||||
];
|
||||
|
||||
/// The tall hidden-inset titlebar geometry is delivered before the first
|
||||
/// view build and whenever the window chrome changes.
|
||||
export const chromeMsg = "chrome_changed";
|
||||
|
||||
/// Update-only state: host-fired Msg arms and the fields markup reads
|
||||
/// through the exported derived helpers instead of directly.
|
||||
export const viewUnbound = [
|
||||
"chat_line",
|
||||
"chat_done",
|
||||
"chat_failed",
|
||||
"chrome_changed",
|
||||
"model_set",
|
||||
"key_set",
|
||||
"turns",
|
||||
"nextId",
|
||||
"phase",
|
||||
"failReason",
|
||||
"pendingReply",
|
||||
"streamDone",
|
||||
"draft",
|
||||
"modelName",
|
||||
"apiKey",
|
||||
"scrollPulse",
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------- derived
|
||||
|
||||
function isConfigured(model: Model): boolean {
|
||||
return model.apiKey.length > 0;
|
||||
}
|
||||
|
||||
/// Composer whitespace includes LF, unlike the line-parser helper: a
|
||||
/// Shift+Enter-only draft must not issue a blank request.
|
||||
function trimComposerWhitespace(text: Bytes): Bytes {
|
||||
let start = 0;
|
||||
let end = text.length;
|
||||
while (start < end && isAsciiWhitespace(text[start])) start += 1;
|
||||
while (end > start && isAsciiWhitespace(text[end - 1])) end -= 1;
|
||||
return text.subarray(start, end);
|
||||
}
|
||||
|
||||
function isAsciiWhitespace(byte: number): boolean {
|
||||
return byte === 0x20 || (byte >= 0x09 && byte <= 0x0d);
|
||||
}
|
||||
|
||||
/// The teaching state: the Gateway key is missing, so the app can only
|
||||
/// explain how to connect it — and issues zero requests.
|
||||
export function unconfigured(model: Model): boolean {
|
||||
return !isConfigured(model);
|
||||
}
|
||||
|
||||
export function keyMissing(model: Model): boolean {
|
||||
return model.apiKey.length === 0;
|
||||
}
|
||||
|
||||
export function sending(model: Model): boolean {
|
||||
return model.phase === "sending";
|
||||
}
|
||||
|
||||
export function failed(model: Model): boolean {
|
||||
return model.phase === "failed";
|
||||
}
|
||||
|
||||
export function failReasonLabel(model: Model): Bytes {
|
||||
return model.failReason;
|
||||
}
|
||||
|
||||
export function pendingReplyLabel(model: Model): Bytes {
|
||||
return model.pendingReply;
|
||||
}
|
||||
|
||||
export function waitingForFirstToken(model: Model): boolean {
|
||||
return model.phase === "sending" && model.pendingReply.length === 0;
|
||||
}
|
||||
|
||||
export function draftText(model: Model): Bytes {
|
||||
return model.draft.bytes;
|
||||
}
|
||||
|
||||
export function emptyConversation(model: Model): boolean {
|
||||
return model.turns.length === 0;
|
||||
}
|
||||
|
||||
export function sendDisabled(model: Model): boolean {
|
||||
return !isConfigured(model);
|
||||
}
|
||||
|
||||
export function modelNameLabel(model: Model): Bytes {
|
||||
if (sameBytes(model.modelName, DEFAULT_MODEL)) return MODEL_LABEL_LUNA;
|
||||
if (sameBytes(model.modelName, MODEL_TERRA)) return MODEL_LABEL_TERRA;
|
||||
if (sameBytes(model.modelName, MODEL_SOL)) return MODEL_LABEL_SOL;
|
||||
return model.modelName;
|
||||
}
|
||||
|
||||
function sameBytes(left: Bytes, right: Bytes): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
if (left[i] !== right[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function modelIsSol(model: Model): boolean {
|
||||
return sameBytes(model.modelName, MODEL_SOL);
|
||||
}
|
||||
|
||||
export function modelIsLuna(model: Model): boolean {
|
||||
return sameBytes(model.modelName, DEFAULT_MODEL);
|
||||
}
|
||||
|
||||
export function modelIsTerra(model: Model): boolean {
|
||||
return sameBytes(model.modelName, MODEL_TERRA);
|
||||
}
|
||||
|
||||
/// One conversation row for markup's `for each`: the role flag picks the
|
||||
/// user-bubble or plain-assistant-text presentation.
|
||||
export interface TurnRow {
|
||||
readonly id: number;
|
||||
readonly user: boolean;
|
||||
readonly text: Bytes;
|
||||
}
|
||||
|
||||
export function turnRows(model: Model): readonly TurnRow[] {
|
||||
return model.turns.map((t) => ({ id: t.id, user: t.role === "user", text: t.text }));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- update
|
||||
|
||||
export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "draft_edit":
|
||||
return [{ ...model, draft: composerApply(model.draft, msg.edit) }, Cmd.none];
|
||||
case "toggle_model_picker":
|
||||
return [{
|
||||
...model,
|
||||
modelPickerOpen: !model.modelPickerOpen,
|
||||
promptAutofocus: model.modelPickerOpen ? model.promptAutofocus : false,
|
||||
}, Cmd.none];
|
||||
case "close_model_picker":
|
||||
if (!model.modelPickerOpen) return [model, Cmd.none];
|
||||
return [{ ...model, modelPickerOpen: false }, Cmd.none];
|
||||
case "pick_model_sol":
|
||||
return [{ ...model, modelName: MODEL_SOL, modelPickerOpen: false, promptAutofocus: true }, Cmd.none];
|
||||
case "pick_model_luna":
|
||||
return [{ ...model, modelName: DEFAULT_MODEL, modelPickerOpen: false, promptAutofocus: true }, Cmd.none];
|
||||
case "pick_model_terra":
|
||||
return [{ ...model, modelName: MODEL_TERRA, modelPickerOpen: false, promptAutofocus: true }, Cmd.none];
|
||||
case "send": {
|
||||
// The in-flight guard: one request at a time, by model state — a
|
||||
// second send while one is out is a no-op, so the "chat" key can
|
||||
// never collide at the engine.
|
||||
if (!isConfigured(model) || model.phase === "sending") return [model, Cmd.none];
|
||||
const text = trimComposerWhitespace(model.draft.bytes);
|
||||
if (text.length === 0) return [model, Cmd.none];
|
||||
const turns: readonly Turn[] = [...model.turns, { id: model.nextId, role: "user", text: text }];
|
||||
const body = encodeChatRequestWithinLimit(model.modelName, SYSTEM_PROMPT, turns, MAX_REQUEST_BODY);
|
||||
const next: Model = {
|
||||
...model,
|
||||
turns: turns,
|
||||
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
|
||||
phase: "sending",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
draft: composerInit(),
|
||||
modelPickerOpen: false,
|
||||
promptAutofocus: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
};
|
||||
if (body.length === 0) {
|
||||
return [{ ...next, phase: "failed", failReason: REQUEST_TOO_LARGE }, Cmd.none];
|
||||
}
|
||||
return [
|
||||
next,
|
||||
Cmd.fetch(
|
||||
{
|
||||
url: AI_GATEWAY_ENDPOINT,
|
||||
method: "POST",
|
||||
// The bearer token is a RUNTIME header value (built from the
|
||||
// launch-supplied key); header names stay compile-time.
|
||||
headers: {
|
||||
accept: "text/event-stream",
|
||||
authorization: bearerToken(model.apiKey),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: body,
|
||||
timeoutMs: 120000,
|
||||
maxLineBytes: 65536,
|
||||
},
|
||||
{ key: "chat", line: "chat_line", ok: "chat_done", err: "chat_failed" },
|
||||
),
|
||||
];
|
||||
}
|
||||
case "retry": {
|
||||
// Re-send the conversation as it stands: only from the failed
|
||||
// state, and only when the last turn is the unanswered user turn.
|
||||
if (model.phase !== "failed" || !isConfigured(model)) return [model, Cmd.none];
|
||||
if (model.turns.length === 0) return [model, Cmd.none];
|
||||
if (model.turns[model.turns.length - 1].role !== "user") return [model, Cmd.none];
|
||||
const body = encodeChatRequestWithinLimit(model.modelName, SYSTEM_PROMPT, model.turns, MAX_REQUEST_BODY);
|
||||
if (body.length === 0) return [{ ...model, failReason: REQUEST_TOO_LARGE }, Cmd.none];
|
||||
return [
|
||||
{
|
||||
...model,
|
||||
phase: "sending",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
promptAutofocus: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
},
|
||||
Cmd.fetch(
|
||||
{
|
||||
url: AI_GATEWAY_ENDPOINT,
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "text/event-stream",
|
||||
authorization: bearerToken(model.apiKey),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: body,
|
||||
timeoutMs: 120000,
|
||||
maxLineBytes: 65536,
|
||||
},
|
||||
{ key: "chat", line: "chat_line", ok: "chat_done", err: "chat_failed" },
|
||||
),
|
||||
];
|
||||
}
|
||||
case "stop": {
|
||||
if (model.phase !== "sending") return [model, Cmd.none];
|
||||
if (model.pendingReply.length === 0) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
promptAutofocus: true,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.cancel("chat")];
|
||||
}
|
||||
return [{
|
||||
...model,
|
||||
turns: [...model.turns, { id: model.nextId, role: "assistant", text: model.pendingReply }],
|
||||
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
promptAutofocus: true,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.cancel("chat")];
|
||||
}
|
||||
case "clear": {
|
||||
const next: Model = {
|
||||
...model,
|
||||
turns: [],
|
||||
nextId: 1,
|
||||
phase: "idle",
|
||||
failReason: new Uint8Array(0),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
draft: composerInit(),
|
||||
modelPickerOpen: false,
|
||||
chatScrollTop: 0,
|
||||
scrollPulse: false,
|
||||
};
|
||||
// Starting a new chat is always available. If a reply is live,
|
||||
// close its keyed stream; the resulting cancelled terminal is
|
||||
// stale once phase is idle and is deliberately ignored below.
|
||||
if (model.phase === "sending") return [next, Cmd.cancel("chat")];
|
||||
return [next, Cmd.none];
|
||||
}
|
||||
case "chat_line": {
|
||||
// The "chat" key carries exactly one live request and the sending
|
||||
// guard blocks re-sends, so a line outside the sending phase
|
||||
// can only be stale — drop it rather than corrupt the history.
|
||||
if (model.phase !== "sending") return [model, Cmd.none];
|
||||
if (model.streamDone) return [model, Cmd.none];
|
||||
const event = parseChatStreamLine(msg.line);
|
||||
switch (event.kind) {
|
||||
case "ignore":
|
||||
return [model, Cmd.none];
|
||||
case "delta": {
|
||||
if (model.pendingReply.length + event.text.length > MAX_REPLY) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: asciiBytes("the streamed reply exceeded 256 KiB"),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.cancel("chat")];
|
||||
}
|
||||
return [{
|
||||
...model,
|
||||
pendingReply: concatAll([model.pendingReply, event.text]),
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
case "done":
|
||||
return [{ ...model, streamDone: true }, Cmd.none];
|
||||
case "error":
|
||||
// Error bodies can be SSE data or plain JSON lines. Preserve
|
||||
// the Gateway's own message for the terminal status handler.
|
||||
return [{ ...model, failReason: event.message }, Cmd.none];
|
||||
}
|
||||
}
|
||||
case "chat_done": {
|
||||
if (model.phase !== "sending") return [model, Cmd.none];
|
||||
if (model.failReason.length > 0) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
if (msg.status < 200 || msg.status >= 300) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: asciiBytes(`Vercel AI Gateway answered HTTP ${msg.status}`),
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
if (!model.streamDone) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: asciiBytes("the response stream ended before [DONE]"),
|
||||
pendingReply: new Uint8Array(0),
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
if (model.pendingReply.length === 0) {
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: asciiBytes("the response stream produced no text"),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
return [{
|
||||
...model,
|
||||
turns: [...model.turns, { id: model.nextId, role: "assistant", text: model.pendingReply }],
|
||||
nextId: model.nextId < 9007199254740991 ? model.nextId + 1 : 9007199254740991,
|
||||
phase: "idle",
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
}
|
||||
case "chat_failed":
|
||||
// The transport reason is machine-readable (`timed_out`,
|
||||
// `connect_failed`, `truncated`, ...) — shown as-is, never silence.
|
||||
if (model.phase !== "sending") return [model, Cmd.none];
|
||||
return [{
|
||||
...model,
|
||||
phase: "failed",
|
||||
failReason: msg.reason,
|
||||
pendingReply: new Uint8Array(0),
|
||||
streamDone: false,
|
||||
chatScrollTop: model.scrollPulse ? SCROLL_BOTTOM - 1 : SCROLL_BOTTOM,
|
||||
scrollPulse: !model.scrollPulse,
|
||||
}, Cmd.none];
|
||||
case "chat_scrolled":
|
||||
// The controlled-scroll echo: the applied offset lands in the
|
||||
// model, so the next rebuild's `value` binding never fights the
|
||||
// runtime.
|
||||
return [{ ...model, chatScrollTop: msg.scroll.offsetY }, Cmd.none];
|
||||
case "chrome_changed":
|
||||
return [{
|
||||
...model,
|
||||
chromeLeading: msg.insets.left,
|
||||
headerHeight: Math.max(HEADER_NATURAL_HEIGHT, msg.insets.top),
|
||||
}, Cmd.none];
|
||||
case "model_set":
|
||||
// An explicitly empty optional override does not erase the
|
||||
// built-in default.
|
||||
if (msg.value.length === 0) return [model, Cmd.none];
|
||||
return [{ ...model, modelName: msg.value }, Cmd.none];
|
||||
case "key_set":
|
||||
return [{ ...model, apiKey: msg.value }, Cmd.none];
|
||||
}
|
||||
}
|
||||
@@ -1506,7 +1506,7 @@ test "the empty and loaded views expose the picker, tree, and highlighted code s
|
||||
);
|
||||
|
||||
const editor = findByText(tree.root, .textarea, source).?;
|
||||
try testing.expect(editor.code_editor);
|
||||
try testing.expect(editor.runtime_flags.code_editor);
|
||||
try testing.expectEqual(@as(usize, 1), editor.spans.len);
|
||||
try testing.expectEqual(native_sdk.code.Language.tsx, editor.code_language);
|
||||
try testing.expectEqual(native_sdk.geometry.InsetsF{}, editor.layout.padding);
|
||||
|
||||
@@ -221,9 +221,12 @@ fn linkPlatform(b: *std.Build, target: std.Build.ResolvedTarget, app_mod: *std.B
|
||||
if (b.sysroot) |sysroot| {
|
||||
app_mod.addFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "System/Library/Frameworks" }) });
|
||||
}
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/macos/capture_info_plist.c"), .flags = &.{} });
|
||||
app_mod.linkFramework("AppKit", .{});
|
||||
// The audio playback service (the AppKit host's single AVPlayer).
|
||||
app_mod.linkFramework("AVFoundation", .{});
|
||||
app_mod.linkFramework("CoreMedia", .{});
|
||||
app_mod.linkFramework("ScreenCaptureKit", .{ .weak = true });
|
||||
// CVPixelBuffer for the video frame path (the AppKit host's
|
||||
// AVPlayerItemVideoOutput frames are real CoreVideo symbols).
|
||||
app_mod.linkFramework("CoreVideo", .{});
|
||||
@@ -295,10 +298,13 @@ fn linkPlatform(b: *std.Build, target: std.Build.ResolvedTarget, app_mod: *std.B
|
||||
app_mod.addLibraryPath(b.path(b.fmt("{s}/Release", .{cef_dir})));
|
||||
},
|
||||
}
|
||||
app_mod.addCSourceFile(.{ .file = nativeSdkPath(b, native_sdk_path, "src/platform/windows/gpu_surface_renderer.cpp"), .flags = &.{"-std=c++17"} });
|
||||
app_mod.linkSystemLibrary("c", .{});
|
||||
app_mod.linkSystemLibrary("c++", .{});
|
||||
app_mod.linkSystemLibrary("user32", .{});
|
||||
app_mod.linkSystemLibrary("gdi32", .{});
|
||||
app_mod.linkSystemLibrary("d2d1", .{});
|
||||
app_mod.linkSystemLibrary("dwrite", .{});
|
||||
app_mod.linkSystemLibrary("imm32", .{});
|
||||
app_mod.linkSystemLibrary("comctl32", .{});
|
||||
app_mod.linkSystemLibrary("ole32", .{});
|
||||
|
||||
@@ -241,7 +241,7 @@ test "command app routes toolbar menu tray shortcut and bridge commands" {
|
||||
.window_id = 1,
|
||||
} });
|
||||
try std.testing.expectEqual(@as(usize, 1), harness.null_platform.trayCreateCount());
|
||||
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .tray_action = 1 });
|
||||
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .tray_action = .{ .item_id = 1 } });
|
||||
try harness.runtime.dispatchPlatformEvent(app.app(), .{ .shortcut = .{
|
||||
.id = command_id,
|
||||
.key = "s",
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
# Native SDK gpu-components example
|
||||
|
||||
This example is a retained GPU widget lab for trying the finished native-first component surface:
|
||||
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.
|
||||
|
||||
- Native toolbar shell view with native-sdk-rendered sidebar, status strip, and GPU component surface.
|
||||
- Buttons, icon buttons, text, icons, fields, checkbox, toggle, slider, progress, segmented control, lists, scroll views, popovers, menus, tooltips, and data grids.
|
||||
- Built-in component catalog in the house style: Accordion, Alert, Avatar, Badge, Breadcrumb, Bubble, Button, Button Group, Card, Checkbox, Combobox, Dialog, Drawer, Dropdown Menu, Input, Pagination, Progress, Radio Group, Resizable, Select, Separator, Sheet, Skeleton, Slider, Spinner, Switch, Table, Tabs, Textarea, Toggle, Toggle Group, and Tooltip.
|
||||
- Retained widget semantics for focus, press, toggle, select, text editing, scrolling, and data-grid roles.
|
||||
- Token-driven rounded corners, shadows, blur, typography, color, and scroll physics.
|
||||
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.
|
||||
|
||||
Run with the macOS system backend. The GPU component lab defaults to `ReleaseFast`; pass `-Doptimize=Debug` only when debugging renderer internals.
|
||||
Run the app with the repository CLI:
|
||||
|
||||
```sh
|
||||
native dev
|
||||
```
|
||||
|
||||
Run the headless canvas and scene tests:
|
||||
Compile the TypeScript core, validate the markup contract, and run the generated headless app suite:
|
||||
|
||||
```sh
|
||||
native test -Dplatform=null
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.id = "dev.native_sdk.gpu_components",
|
||||
.name = "gpu-components",
|
||||
.display_name = "GPU Components",
|
||||
.description = "An isolated gallery of interactive Native UI components authored in TypeScript and Native markup.",
|
||||
.version = "0.1.0",
|
||||
.platforms = .{"macos"},
|
||||
.permissions = .{ "view", "command" },
|
||||
@@ -13,10 +14,12 @@
|
||||
.title = "Native SDK GPU Components",
|
||||
.width = 1180,
|
||||
.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-rendered component canvas", .accessibility_label = "Native-rendered component gallery canvas", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
|
||||
.{ .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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "gpu-components",
|
||||
"private": true,
|
||||
"description": "Editor surface for the TypeScript core; the native CLI builds without node_modules.",
|
||||
"dependencies": {
|
||||
"@native-sdk/core": "0.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
<!-- The GPU component gallery is deliberately only two things: a component
|
||||
tree and one isolated, interactive specimen. The tree and every specimen
|
||||
use the same built-in markup components an application author uses. -->
|
||||
<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>
|
||||
</toggle-group>
|
||||
</column>
|
||||
<separator />
|
||||
<scroll grow="1" label="Scrollable component tree">
|
||||
<tree gap="2" label="Components">
|
||||
<list-item
|
||||
icon="folder-open"
|
||||
role="treeitem"
|
||||
tree-level="1"
|
||||
expanded="{catalogExpanded}"
|
||||
on-toggle="toggle_catalog"
|
||||
on-press="action"
|
||||
on-change="action"
|
||||
>Components</list-item>
|
||||
<if test="{catalogExpanded}">
|
||||
<row>
|
||||
<spacer width="14" />
|
||||
<column grow="1" gap="2">
|
||||
<for each="components" key="id" as="c">
|
||||
<list-item
|
||||
role="treeitem"
|
||||
tree-level="2"
|
||||
selected="{c.id == selectedComponentId}"
|
||||
on-press="select_component:{c.id}"
|
||||
on-change="select_component:{c.id}"
|
||||
>{c.label}</list-item>
|
||||
</for>
|
||||
</column>
|
||||
</row>
|
||||
</if>
|
||||
</tree>
|
||||
</scroll>
|
||||
</column>
|
||||
|
||||
<separator width="1" />
|
||||
|
||||
<scroll grow="1" label="Selected component specimen">
|
||||
<column padding="32" gap="20">
|
||||
<column gap="6">
|
||||
<text size="heading">{selectedLabel}</text>
|
||||
<text foreground="text_muted">Interactive built-in component specimen</text>
|
||||
</column>
|
||||
<separator />
|
||||
|
||||
<if test="{selectedComponentId == 1}">
|
||||
<column width="440">
|
||||
<accordion text="Details" selected="{accordionOpen}" on-toggle="toggle_accordion">
|
||||
<column padding="12" gap="8">
|
||||
<text wrap="true" foreground="text_muted">Accordion details are visible. The model owns this expanded state.</text>
|
||||
</column>
|
||||
</accordion>
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 2}">
|
||||
<column width="440" gap="12">
|
||||
<alert text="A new version is available.">
|
||||
<text wrap="true" foreground="text_muted">Restart the app to finish updating.</text>
|
||||
</alert>
|
||||
<alert text="Your session has expired." variant="destructive" />
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 3}">
|
||||
<row gap="12" cross="center">
|
||||
<avatar label="Zig user">ZN</avatar>
|
||||
<avatar label="TypeScript user">TS</avatar>
|
||||
<avatar label="Native SDK user">NS</avatar>
|
||||
</row>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 4}">
|
||||
<row gap="10" cross="center">
|
||||
<badge>Default</badge>
|
||||
<badge variant="secondary">Secondary</badge>
|
||||
<badge variant="outline">Outline</badge>
|
||||
<badge variant="destructive">Destructive</badge>
|
||||
<badge variant="secondary" icon="check">Verified</badge>
|
||||
</row>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 5}">
|
||||
<breadcrumb gap="8" cross="center">
|
||||
<text foreground="text_muted" on-press="action">Home</text>
|
||||
<icon name="chevron-right" foreground="text_muted" />
|
||||
<text foreground="text_muted" on-press="action">Components</text>
|
||||
<icon name="chevron-right" foreground="text_muted" />
|
||||
<text>Breadcrumb</text>
|
||||
</breadcrumb>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 6}">
|
||||
<column width="440" gap="28">
|
||||
<column gap="8">
|
||||
<bubble>
|
||||
<text wrap="true">Ready to ship the TypeScript component gallery?</text>
|
||||
</bubble>
|
||||
<bubble>
|
||||
<text wrap="true">Every specimen is rendered from Native markup.</text>
|
||||
<reactions>+2</reactions>
|
||||
</bubble>
|
||||
</column>
|
||||
<column cross="end">
|
||||
<bubble variant="primary">
|
||||
<text wrap="true">Looks good to me.</text>
|
||||
</bubble>
|
||||
</column>
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 7}">
|
||||
<column gap="14">
|
||||
<row gap="12" cross="center">
|
||||
<button variant="primary" on-press="action">Primary</button>
|
||||
<button variant="secondary" on-press="action">Secondary</button>
|
||||
<button variant="outline" on-press="action">Outline</button>
|
||||
<button variant="ghost" on-press="action">Ghost</button>
|
||||
<button variant="destructive" on-press="action">Delete</button>
|
||||
</row>
|
||||
<row gap="12" cross="center">
|
||||
<button size="sm" variant="outline" on-press="action">Small</button>
|
||||
<button variant="outline" on-press="action">Default</button>
|
||||
<button size="lg" variant="outline" on-press="action">Large</button>
|
||||
<button size="icon" icon="plus" label="Add" on-press="action" />
|
||||
<button disabled="true">Disabled</button>
|
||||
</row>
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 8}">
|
||||
<button-group>
|
||||
<button variant="outline" on-press="action">Cut</button>
|
||||
<button variant="outline" on-press="action">Copy</button>
|
||||
<button variant="outline" on-press="action">Paste</button>
|
||||
</button-group>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 9}">
|
||||
<card width="360">
|
||||
<column gap="12">
|
||||
<text>Deploy your app</text>
|
||||
<text wrap="true" foreground="text_muted">Package the TypeScript core and Native markup into one native binary.</text>
|
||||
<row gap="8">
|
||||
<button variant="primary" on-press="action">Deploy</button>
|
||||
<button variant="ghost" on-press="action">Cancel</button>
|
||||
</row>
|
||||
</column>
|
||||
</card>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 10}">
|
||||
<column gap="12">
|
||||
<checkbox checked="{checkboxChecked}" on-toggle="toggle_checkbox" text="Accept terms and conditions" />
|
||||
<checkbox checked="{usageReportsChecked}" on-toggle="toggle_usage_reports" text="Send usage reports" />
|
||||
<checkbox checked="true" disabled="true" text="Managed by your organization" />
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 11}">
|
||||
<stack width="280">
|
||||
<combobox placeholder="Search frameworks" text="{comboboxText}" on-input="combobox_edited" on-press="open_combobox" />
|
||||
<if test="{comboboxOpen}">
|
||||
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_combobox">
|
||||
<menu-item icon="search" on-press="choose_native">Native SDK</menu-item>
|
||||
<menu-item icon="panel-left" on-press="choose_canvas">Canvas</menu-item>
|
||||
</dropdown-menu>
|
||||
</if>
|
||||
</stack>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 12}">
|
||||
<column>
|
||||
<button variant="outline" on-press="open_dialog">Open dialog</button>
|
||||
<if test="{dialogOpen}">
|
||||
<dialog text="Confirm deployment" width="400" height="230" padding="24" on-dismiss="close_dialog">
|
||||
<column gap="14">
|
||||
<spacer height="34" />
|
||||
<text wrap="true" foreground="text_muted">Deploy this build to the production environment?</text>
|
||||
<row gap="8" main="end">
|
||||
<button variant="ghost" on-press="close_dialog">Cancel</button>
|
||||
<button variant="primary" on-press="close_dialog">Deploy</button>
|
||||
</row>
|
||||
</column>
|
||||
</dialog>
|
||||
</if>
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 13}">
|
||||
<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">
|
||||
<column gap="12">
|
||||
<spacer height="34" />
|
||||
<checkbox checked="{onlyUnreadChecked}" on-toggle="toggle_only_unread" text="Only unread" />
|
||||
<checkbox checked="{hasAttachmentsChecked}" on-toggle="toggle_has_attachments" text="Has attachments" />
|
||||
<switch checked="{compactRows}" on-toggle="toggle_compact_rows">Compact rows</switch>
|
||||
</column>
|
||||
</drawer>
|
||||
</if>
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 14}">
|
||||
<column width="240" gap="10">
|
||||
<stack>
|
||||
<button variant="outline" icon="chevron-down" on-press="toggle_dropdown">Actions</button>
|
||||
<if test="{dropdownOpen}">
|
||||
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_dropdown">
|
||||
<menu-item icon="copy" on-press="dropdown_duplicate">Duplicate</menu-item>
|
||||
<menu-item icon="edit" on-press="dropdown_rename">Rename</menu-item>
|
||||
<menu-item icon="download" on-press="dropdown_download">Download</menu-item>
|
||||
<separator />
|
||||
<menu-item icon="trash" on-press="dropdown_delete">Delete</menu-item>
|
||||
</dropdown-menu>
|
||||
</if>
|
||||
</stack>
|
||||
<text foreground="text_muted">{dropdownStatus}</text>
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 15}">
|
||||
<column width="360" gap="12">
|
||||
<input placeholder="Email address" text="{inputText}" on-input="input_edited" label="Email address" />
|
||||
<input placeholder="Disabled" disabled="true" label="Disabled input" />
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 16}">
|
||||
<pagination gap="4">
|
||||
<button variant="ghost" icon="chevron-left" on-press="previous_page">Previous</button>
|
||||
<button variant="outline" selected="{page == 1}" on-press="page_one">1</button>
|
||||
<button variant="ghost" selected="{page == 2}" on-press="page_two">2</button>
|
||||
<button variant="ghost" selected="{page == 3}" on-press="page_three">3</button>
|
||||
<icon name="ellipsis" />
|
||||
<button variant="ghost" icon="chevron-right" icon-placement="trailing" on-press="next_page">Next</button>
|
||||
</pagination>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 17}">
|
||||
<column width="320" gap="10">
|
||||
<progress value="{sliderValue}" label="Upload progress" />
|
||||
<text foreground="text_muted">Build upload progress</text>
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 18}">
|
||||
<radio-group gap="12" label="Density">
|
||||
<radio checked="{density == 'default'}" on-toggle="density_default" text="Default" />
|
||||
<radio checked="{density == 'comfortable'}" on-toggle="density_comfortable" text="Comfortable" />
|
||||
<radio checked="false" disabled="true" text="Compact" />
|
||||
</radio-group>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 19}">
|
||||
<row height="220" grow="1">
|
||||
<resizable width="280" min-width="180">
|
||||
<column padding="16" gap="8">
|
||||
<text>Resizable panel</text>
|
||||
<text wrap="true" foreground="text_muted">Drag the trailing edge to change this panel's width.</text>
|
||||
</column>
|
||||
</resizable>
|
||||
</row>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 20}">
|
||||
<stack width="280">
|
||||
<select on-press="toggle_select" label="Environment selector">{selectLabel}</select>
|
||||
<if test="{selectOpen}">
|
||||
<dropdown-menu anchor="below" anchor-alignment="stretch" on-dismiss="close_select">
|
||||
<menu-item selected="{selectChoice == 'production'}" on-press="select_production">Production</menu-item>
|
||||
<menu-item selected="{selectChoice == 'staging'}" on-press="select_staging">Staging</menu-item>
|
||||
<menu-item selected="{selectChoice == 'development'}" on-press="select_development">Development</menu-item>
|
||||
</dropdown-menu>
|
||||
</if>
|
||||
</stack>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 21}">
|
||||
<column width="440" gap="24">
|
||||
<separator />
|
||||
<row gap="16" cross="center">
|
||||
<text>One</text>
|
||||
<separator width="1" height="24" />
|
||||
<text>Two</text>
|
||||
<separator width="1" height="24" />
|
||||
<text>Three</text>
|
||||
</row>
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 22}">
|
||||
<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">
|
||||
<column gap="12">
|
||||
<spacer height="34" />
|
||||
<text wrap="true" foreground="text_muted">Anyone with the link can view this component gallery.</text>
|
||||
<row gap="8">
|
||||
<input text="https://native-sdk.dev/components" label="Share link" grow="1" />
|
||||
<button variant="secondary" icon="copy" on-press="action">Copy</button>
|
||||
</row>
|
||||
</column>
|
||||
</sheet>
|
||||
</if>
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 23}">
|
||||
<row gap="12" cross="center" width="420">
|
||||
<skeleton width="48" height="48" />
|
||||
<column grow="1" gap="10">
|
||||
<skeleton height="14" />
|
||||
<skeleton width="220" height="14" />
|
||||
</column>
|
||||
</row>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 24}">
|
||||
<column width="320" gap="20">
|
||||
<slider value="{sliderValue}" on-change="slider_changed" label="Volume" />
|
||||
<slider value="0.72" disabled="true" label="Disabled slider" />
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 25}">
|
||||
<row gap="12" cross="center">
|
||||
<spinner />
|
||||
<text foreground="text_muted">Loading components...</text>
|
||||
</row>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 26}">
|
||||
<column gap="12">
|
||||
<switch checked="{notificationsEnabled}" on-toggle="toggle_notifications">Notifications</switch>
|
||||
<switch checked="{airplaneMode}" on-toggle="toggle_airplane_mode">Airplane mode</switch>
|
||||
<switch checked="true" disabled="true">Managed setting</switch>
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 27}">
|
||||
<table width="520">
|
||||
<table-row>
|
||||
<table-cell grow="1" size="sm" foreground="text_muted">Invoice</table-cell>
|
||||
<table-cell grow="1" size="sm" foreground="text_muted">Status</table-cell>
|
||||
<table-cell grow="1" size="sm" foreground="text_muted" text-alignment="end">Amount</table-cell>
|
||||
</table-row>
|
||||
<table-row>
|
||||
<table-cell grow="1" on-press="action">INV-001</table-cell>
|
||||
<table-cell grow="1">Paid</table-cell>
|
||||
<table-cell grow="1" text-alignment="end">$250.00</table-cell>
|
||||
</table-row>
|
||||
<table-row selected="true">
|
||||
<table-cell grow="1" on-press="action">INV-002</table-cell>
|
||||
<table-cell grow="1">Pending</table-cell>
|
||||
<table-cell grow="1" text-alignment="end">$150.00</table-cell>
|
||||
</table-row>
|
||||
<table-row>
|
||||
<table-cell grow="1" on-press="action">INV-003</table-cell>
|
||||
<table-cell grow="1">Overdue</table-cell>
|
||||
<table-cell grow="1" text-alignment="end">$350.00</table-cell>
|
||||
</table-row>
|
||||
</table>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 28}">
|
||||
<column gap="18">
|
||||
<row>
|
||||
<tabs label="Account settings">
|
||||
<button selected="{tab == 'account'}" on-press="tab_account">Account</button>
|
||||
<button selected="{tab == 'password'}" on-press="tab_password">Password</button>
|
||||
<button selected="{tab == 'team'}" on-press="tab_team">Team</button>
|
||||
</tabs>
|
||||
</row>
|
||||
<if test="{tab == 'account'}"><text foreground="text_muted">Manage your account details.</text></if>
|
||||
<if test="{tab == 'password'}"><text foreground="text_muted">Change your password.</text></if>
|
||||
<if test="{tab == 'team'}"><text foreground="text_muted">Manage your team.</text></if>
|
||||
</column>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 29}">
|
||||
<textarea width="420" height="130" placeholder="Write a release note" text="{textareaText}" on-input="textarea_edited" label="Release note" />
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 30}">
|
||||
<row gap="12" cross="center">
|
||||
<toggle checked="{bold}" on-toggle="toggle_bold">Bold</toggle>
|
||||
<toggle checked="{italic}" on-toggle="toggle_italic">Italic</toggle>
|
||||
<toggle disabled="true">Underline</toggle>
|
||||
</row>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 31}">
|
||||
<toggle-group label="Text alignment">
|
||||
<toggle-button selected="{alignment == 'left'}" on-toggle="align_left">Left</toggle-button>
|
||||
<toggle-button selected="{alignment == 'center'}" on-toggle="align_center">Center</toggle-button>
|
||||
<toggle-button selected="{alignment == 'right'}" on-toggle="align_right">Right</toggle-button>
|
||||
</toggle-group>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 32}">
|
||||
<row padding="48">
|
||||
<stack>
|
||||
<button variant="outline" icon="edit" label="Bold" on-press="toggle_bold" />
|
||||
<tooltip anchor="above" tooltip-delay="0">Bold the selection</tooltip>
|
||||
</stack>
|
||||
</row>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 33}">
|
||||
<list width="380" gap="2">
|
||||
<list-item icon="file-text" selected="{listSelection == 'report'}" on-press="list_report">Quarterly report.md</list-item>
|
||||
<list-item icon="file-text" selected="{listSelection == 'checklist'}" on-press="list_checklist">Launch checklist.md</list-item>
|
||||
<list-item icon="folder" selected="{listSelection == 'archive'}" on-press="list_archive">Archive</list-item>
|
||||
<list-item icon="music" disabled="true">demo-track.wav</list-item>
|
||||
</list>
|
||||
</if>
|
||||
|
||||
<if test="{selectedComponentId == 34}">
|
||||
<tree width="380" gap="2" label="Files">
|
||||
<list-item icon="folder-open" role="treeitem" tree-level="1" expanded="{srcExpanded}" selected="{treeSelection == 'src'}" on-toggle="toggle_src" on-press="tree_src" on-change="tree_src">src</list-item>
|
||||
<if test="{srcExpanded}">
|
||||
<row>
|
||||
<spacer width="20" />
|
||||
<column grow="1" gap="2">
|
||||
<list-item icon="file-text" role="treeitem" tree-level="2" selected="{treeSelection == 'main'}" on-press="tree_main" on-change="tree_main">main.ts</list-item>
|
||||
<list-item icon="file-text" role="treeitem" tree-level="2" selected="{treeSelection == 'view'}" on-press="tree_view" on-change="tree_view">app.native</list-item>
|
||||
</column>
|
||||
</row>
|
||||
</if>
|
||||
<list-item icon="folder" role="treeitem" tree-level="1" expanded="{assetsExpanded}" selected="{treeSelection == 'assets'}" on-toggle="toggle_assets" on-press="tree_assets" on-change="tree_assets">assets</list-item>
|
||||
<if test="{assetsExpanded}">
|
||||
<row>
|
||||
<spacer width="20" />
|
||||
<column grow="1" gap="2">
|
||||
<list-item icon="file-text" role="treeitem" tree-level="2" selected="{treeSelection == 'logo'}" on-press="tree_logo" on-change="tree_logo">logo.png</list-item>
|
||||
</column>
|
||||
</row>
|
||||
</if>
|
||||
</tree>
|
||||
</if>
|
||||
</column>
|
||||
</scroll>
|
||||
</row>
|
||||
@@ -1,790 +0,0 @@
|
||||
const std = @import("std");
|
||||
const native_sdk = @import("native_sdk");
|
||||
const model = @import("model.zig");
|
||||
const component_scene = @import("scene.zig");
|
||||
|
||||
const canvas = native_sdk.canvas;
|
||||
const geometry = native_sdk.geometry;
|
||||
|
||||
const window_width = model.window_width;
|
||||
const window_height = model.window_height;
|
||||
const canvas_sidebar_width = model.canvas_sidebar_width;
|
||||
const default_canvas_size = model.default_canvas_size;
|
||||
const max_component_pipelines = model.max_component_pipelines;
|
||||
const max_component_commands = model.max_component_commands;
|
||||
const max_component_glyphs = model.max_component_glyphs;
|
||||
const max_component_widgets = model.max_component_widgets;
|
||||
const component_chrome_prefix_commands = model.component_chrome_prefix_commands;
|
||||
const component_chrome_suffix_commands = model.component_chrome_suffix_commands;
|
||||
const refresh_command = model.refresh_command;
|
||||
const themeModeFromCommand = model.themeModeFromCommand;
|
||||
const environment_toggle_command = model.environment_toggle_command;
|
||||
const surface_dialog_command = model.surface_dialog_command;
|
||||
const surface_drawer_command = model.surface_drawer_command;
|
||||
const surface_sheet_command = model.surface_sheet_command;
|
||||
const surface_close_command = model.surface_close_command;
|
||||
const canvas_label = model.canvas_label;
|
||||
const environment_select_id = model.environment_select_id;
|
||||
const content_scroll_id = model.content_scroll_id;
|
||||
const canvas_toolbar_theme_id = model.canvas_toolbar_theme_id;
|
||||
const canvas_toolbar_refresh_id = model.canvas_toolbar_refresh_id;
|
||||
const canvas_sidebar_resize_handle_id = model.canvas_sidebar_resize_handle_id;
|
||||
const surface_overlay_backdrop_id = model.surface_overlay_backdrop_id;
|
||||
const surface_overlay_id = model.surface_overlay_id;
|
||||
const surface_overlay_close_id = model.surface_overlay_close_id;
|
||||
const surface_overlay_content_parts = model.surface_overlay_content_parts;
|
||||
const max_surface_overlay_animations = model.max_surface_overlay_animations;
|
||||
const preview_images = component_scene.preview_images;
|
||||
const environment_options = model.environment_options;
|
||||
const environment_menu_id = model.environment_menu_id;
|
||||
const initial_component_status_text = model.initial_component_status_text;
|
||||
const max_component_status_text = model.max_component_status_text;
|
||||
const ComponentVirtualScroll = model.ComponentVirtualScroll;
|
||||
const ComponentUiState = model.ComponentUiState;
|
||||
const ComponentSurfaceOverlay = model.ComponentSurfaceOverlay;
|
||||
const ComponentSection = model.ComponentSection;
|
||||
const ComponentThemeMode = model.ComponentThemeMode;
|
||||
const environmentLabel = model.environmentLabel;
|
||||
const environmentOptionIndex = model.environmentOptionIndex;
|
||||
const environmentCommandIndex = model.environmentCommandIndex;
|
||||
const componentSectionLabel = model.componentSectionLabel;
|
||||
const componentSectionFromCommand = model.componentSectionFromCommand;
|
||||
const surfaceOverlayLabel = model.surfaceOverlayLabel;
|
||||
|
||||
const installComponentsCanvasModel = component_scene.installComponentsCanvasModel;
|
||||
const componentSurfaceSize = component_scene.componentSurfaceSize;
|
||||
const componentSidebarWidthForSize = component_scene.componentSidebarWidthForSize;
|
||||
const componentVirtualScrollTarget = component_scene.componentVirtualScrollTarget;
|
||||
const componentVirtualKeyboardScrollTarget = component_scene.componentVirtualKeyboardScrollTarget;
|
||||
const componentVirtualKeyboardScrollDelta = component_scene.componentVirtualKeyboardScrollDelta;
|
||||
const snapComponentVirtualScrollOffset = component_scene.snapComponentVirtualScrollOffset;
|
||||
const componentScrollStatesEqual = component_scene.componentScrollStatesEqual;
|
||||
const componentFrameIntervalMs = component_scene.componentFrameIntervalMs;
|
||||
const componentSizesEqual = component_scene.componentSizesEqual;
|
||||
const componentTokensForScaleMotionAndContrast = component_scene.componentTokensForScaleMotionAndContrast;
|
||||
const componentThemeModeForAppearance = component_scene.componentThemeModeForAppearance;
|
||||
const normalizedPixelSnapScale = component_scene.normalizedPixelSnapScale;
|
||||
const buildComponentsWidgetLayoutWithStateSizeAndTokens = component_scene.buildComponentsWidgetLayoutWithStateSizeAndTokens;
|
||||
const surfaceOverlayKind = component_scene.surfaceOverlayKind;
|
||||
const surfaceOverlayFrameForSidebar = component_scene.surfaceOverlayFrameForSidebar;
|
||||
const gpuFrameEvent = component_scene.gpuFrameEvent;
|
||||
const componentFrameStatus = component_scene.componentFrameStatus;
|
||||
|
||||
pub const app_permissions = [_][]const u8{ native_sdk.security.permission_command, native_sdk.security.permission_view };
|
||||
pub const shell_views = [_]native_sdk.ShellView{
|
||||
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .min_width = 640, .layer = 12, .role = "Native-rendered component canvas", .accessibility_label = "Native-rendered component gallery canvas", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true },
|
||||
};
|
||||
pub const shell_windows = [_]native_sdk.ShellWindow{.{
|
||||
.label = "main",
|
||||
.title = "Native SDK GPU Components",
|
||||
.width = window_width,
|
||||
.height = window_height,
|
||||
.restore_state = false,
|
||||
.views = &shell_views,
|
||||
}};
|
||||
pub const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
|
||||
|
||||
pub const GpuComponentsApp = struct {
|
||||
refresh_count: u32 = 0,
|
||||
theme_count: u32 = 0,
|
||||
theme_mode: ComponentThemeMode = .light,
|
||||
theme_overridden: bool = false,
|
||||
reduce_motion: bool = false,
|
||||
high_contrast: bool = false,
|
||||
canvas_installed: bool = false,
|
||||
reported_planned_frame: bool = false,
|
||||
virtual_scroll: ComponentVirtualScroll = .{},
|
||||
environment_select_open: bool = false,
|
||||
environment_index: usize = 0,
|
||||
surface_overlay: ComponentSurfaceOverlay = .none,
|
||||
section: ComponentSection = .controls,
|
||||
sidebar_width: f32 = canvas_sidebar_width,
|
||||
canvas_size: geometry.SizeF = default_canvas_size,
|
||||
pixel_snap_scale: f32 = 1,
|
||||
status_text_storage: [max_component_status_text]u8 = [_]u8{0} ** max_component_status_text,
|
||||
status_text_len: usize = 0,
|
||||
pixels: ?[]u8 = null,
|
||||
scratch: ?[]u8 = null,
|
||||
gpu_commands: [max_component_commands]canvas.CanvasGpuCommand = undefined,
|
||||
packet_json: [native_sdk.platform.max_gpu_surface_packet_json_bytes]u8 = undefined,
|
||||
render_commands: [max_component_commands]canvas.RenderCommand = undefined,
|
||||
render_batches: [max_component_commands]canvas.RenderBatch = undefined,
|
||||
images: [max_component_commands]canvas.RenderImage = undefined,
|
||||
image_cache_entries: [max_component_commands]canvas.RenderImageCacheEntry = undefined,
|
||||
image_cache_actions: [max_component_commands * 2]canvas.RenderImageCacheAction = undefined,
|
||||
pipeline_cache_entries: [max_component_pipelines]canvas.RenderPipelineCacheEntry = undefined,
|
||||
pipeline_cache_actions: [max_component_pipelines * 2]canvas.RenderPipelineCacheAction = undefined,
|
||||
layers: [max_component_commands]canvas.RenderLayer = undefined,
|
||||
layer_cache_entries: [max_component_commands]canvas.RenderLayerCacheEntry = undefined,
|
||||
layer_cache_actions: [max_component_commands * 2]canvas.RenderLayerCacheAction = undefined,
|
||||
resources: [max_component_commands]canvas.RenderResource = undefined,
|
||||
cache_entries: [max_component_commands]canvas.RenderResourceCacheEntry = undefined,
|
||||
cache_actions: [max_component_commands * 2]canvas.RenderResourceCacheAction = undefined,
|
||||
visual_effects: [max_component_commands]canvas.VisualEffect = undefined,
|
||||
visual_effect_cache_entries: [max_component_commands]canvas.VisualEffectCacheEntry = undefined,
|
||||
visual_effect_cache_actions: [max_component_commands * 2]canvas.VisualEffectCacheAction = undefined,
|
||||
glyphs: [max_component_glyphs]canvas.GlyphAtlasEntry = undefined,
|
||||
glyph_cache_entries: [max_component_glyphs]canvas.GlyphAtlasCacheEntry = undefined,
|
||||
glyph_cache_actions: [max_component_glyphs * 2]canvas.GlyphAtlasCacheAction = undefined,
|
||||
text_layout_plans: [max_component_commands]canvas.TextLayoutPlan = undefined,
|
||||
text_layout_lines: [max_component_glyphs]canvas.TextLine = undefined,
|
||||
text_layout_cache_entries: [max_component_commands]canvas.TextLayoutCacheEntry = undefined,
|
||||
text_layout_cache_actions: [max_component_commands * 2]canvas.TextLayoutCacheAction = undefined,
|
||||
changes: [max_component_commands * 2 + 1]canvas.DiffChange = undefined,
|
||||
|
||||
pub fn app(self: *@This()) native_sdk.App {
|
||||
return .{
|
||||
.context = self,
|
||||
.name = "gpu-components",
|
||||
.scene_fn = scene,
|
||||
.event_fn = event,
|
||||
.stop_fn = stop,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *@This()) void {
|
||||
if (self.pixels) |pixels| std.heap.page_allocator.free(pixels);
|
||||
if (self.scratch) |scratch| std.heap.page_allocator.free(scratch);
|
||||
self.pixels = null;
|
||||
self.scratch = null;
|
||||
}
|
||||
|
||||
fn scene(context: *anyopaque) anyerror!native_sdk.ShellConfig {
|
||||
_ = context;
|
||||
return shell_scene;
|
||||
}
|
||||
|
||||
fn event(context: *anyopaque, runtime: *native_sdk.Runtime, event_value: native_sdk.Event) anyerror!void {
|
||||
const self: *@This() = @ptrCast(@alignCast(context));
|
||||
switch (event_value) {
|
||||
.command => |command| {
|
||||
if (std.mem.eql(u8, command.name, environment_toggle_command)) {
|
||||
try self.toggleEnvironmentSelect(runtime, command);
|
||||
} else if (environmentCommandIndex(command.name)) |index| {
|
||||
try self.selectEnvironment(runtime, command, index);
|
||||
} else if (std.mem.eql(u8, command.name, surface_dialog_command)) {
|
||||
try self.openSurfaceOverlay(runtime, command, .dialog);
|
||||
} else if (std.mem.eql(u8, command.name, surface_drawer_command)) {
|
||||
try self.openSurfaceOverlay(runtime, command, .drawer);
|
||||
} else if (std.mem.eql(u8, command.name, surface_sheet_command)) {
|
||||
try self.openSurfaceOverlay(runtime, command, .sheet);
|
||||
} else if (std.mem.eql(u8, command.name, surface_close_command)) {
|
||||
try self.closeSurfaceOverlay(runtime, command);
|
||||
} else if (std.mem.eql(u8, command.name, refresh_command)) {
|
||||
try self.refresh(runtime, command);
|
||||
} else if (themeModeFromCommand(command.name)) |mode| {
|
||||
try self.changeTheme(runtime, command, mode);
|
||||
} else if (componentSectionFromCommand(command.name)) |section| {
|
||||
try self.changeSection(runtime, command, section);
|
||||
}
|
||||
},
|
||||
.gpu_surface_frame => |frame_event| try self.handleGpuFrame(runtime, frame_event),
|
||||
.canvas_widget_pointer => |pointer_event| try self.handleWidgetPointer(runtime, pointer_event),
|
||||
.canvas_widget_keyboard => |keyboard_event| try self.handleWidgetKeyboard(runtime, keyboard_event),
|
||||
.canvas_widget_dismiss => |dismiss_event| try self.handleWidgetDismiss(runtime, dismiss_event),
|
||||
.appearance_changed => |appearance| try self.applySystemAppearance(runtime, appearance),
|
||||
.gpu_surface_resized, .gpu_surface_input, .shortcut, .timer, .effects_wake, .audio, .video, .files_dropped, .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_context_press, .canvas_widget_resize, .canvas_widget_change, .window_closed, .automation_provenance, .lifecycle => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(context: *anyopaque, runtime: *native_sdk.Runtime) anyerror!void {
|
||||
_ = runtime;
|
||||
const self: *@This() = @ptrCast(@alignCast(context));
|
||||
self.deinit();
|
||||
}
|
||||
|
||||
fn handleGpuFrame(self: *@This(), runtime: *native_sdk.Runtime, frame_event: native_sdk.GpuSurfaceFrameEvent) anyerror!void {
|
||||
if (!std.mem.eql(u8, frame_event.label, canvas_label)) return;
|
||||
const first_install = !self.canvas_installed;
|
||||
const scale_changed = self.updatePixelSnapScale(frame_event.scale_factor);
|
||||
const size_changed = self.updateCanvasSize(componentSurfaceSize(frame_event.size));
|
||||
if (first_install or scale_changed or size_changed) {
|
||||
if (first_install) self.setStatusText("Component lab display list presented on the GPU surface.");
|
||||
try installComponentsCanvasModel(runtime, frame_event.window_id, self.virtual_scroll, self.componentUiState(), self.componentTokens(), self.canvas_size);
|
||||
_ = try self.presentComponentsCanvas(runtime, frame_event, true);
|
||||
self.canvas_installed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const scrolled = try self.stepComponentVirtualScrollForFrame(runtime, frame_event);
|
||||
_ = try self.presentComponentsCanvas(runtime, frame_event, frame_event.canvas_frame_full_repaint or scrolled);
|
||||
const current_frame = try runtime.gpuSurfaceFrame(frame_event.window_id, canvas_label);
|
||||
try self.reportFrameStatus(runtime, gpuFrameEvent(current_frame));
|
||||
}
|
||||
|
||||
fn handleWidgetPointer(self: *@This(), runtime: *native_sdk.Runtime, pointer_event: native_sdk.runtime.CanvasWidgetPointerEvent) anyerror!void {
|
||||
if (!std.mem.eql(u8, pointer_event.view_label, canvas_label)) return;
|
||||
const target = pointer_event.target orelse return;
|
||||
switch (pointer_event.pointer.phase) {
|
||||
.move => {
|
||||
if (target.id == canvas_sidebar_resize_handle_id) {
|
||||
try self.resizeSidebar(runtime, pointer_event);
|
||||
return;
|
||||
}
|
||||
},
|
||||
.up => {
|
||||
if (target.id == canvas_sidebar_resize_handle_id) return;
|
||||
if (target.id == surface_overlay_backdrop_id and self.surface_overlay != .none) {
|
||||
self.surface_overlay = .none;
|
||||
_ = runtime.clearCanvasRenderAnimations(pointer_event.window_id, canvas_label) catch {};
|
||||
try self.updateComponentsCanvasModel(runtime, pointer_event.window_id);
|
||||
try self.updateStatus(runtime, pointer_event.window_id, "Surface closed.");
|
||||
return;
|
||||
}
|
||||
if (target.id == environment_select_id or
|
||||
target.id == canvas_toolbar_theme_id or
|
||||
target.id == model.themeModeTriggerId(.light) or
|
||||
target.id == model.themeModeTriggerId(.dark) or
|
||||
target.id == model.themeModeTriggerId(.high) or
|
||||
target.id == canvas_toolbar_refresh_id or
|
||||
environmentOptionIndex(target.id) != null or
|
||||
target.id == 175 or
|
||||
target.id == 176 or
|
||||
target.id == 177 or
|
||||
target.id == surface_overlay_close_id) return;
|
||||
if (self.environment_select_open) {
|
||||
self.environment_select_open = false;
|
||||
try self.updateComponentsCanvasModel(runtime, pointer_event.window_id);
|
||||
try self.updateStatus(runtime, pointer_event.window_id, "Environment menu closed.");
|
||||
return;
|
||||
}
|
||||
try self.reportWidgetInteraction(runtime, pointer_event.window_id, "Clicked", target.id);
|
||||
},
|
||||
.wheel => {
|
||||
_ = try self.scrollVirtualWidget(runtime, pointer_event);
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn resizeSidebar(self: *@This(), runtime: *native_sdk.Runtime, pointer_event: native_sdk.runtime.CanvasWidgetPointerEvent) anyerror!void {
|
||||
const next_width = componentSidebarWidthForSize(self.sidebar_width + pointer_event.pointer.delta.dx, self.canvas_size);
|
||||
if (@abs(next_width - self.sidebar_width) < 0.001) return;
|
||||
self.sidebar_width = next_width;
|
||||
try installComponentsCanvasModel(runtime, pointer_event.window_id, self.virtual_scroll, self.componentUiState(), self.componentTokens(), self.canvas_size);
|
||||
}
|
||||
|
||||
fn handleWidgetKeyboard(self: *@This(), runtime: *native_sdk.Runtime, keyboard_event: native_sdk.runtime.CanvasWidgetKeyboardEvent) anyerror!void {
|
||||
if (!std.mem.eql(u8, keyboard_event.view_label, canvas_label)) return;
|
||||
if (keyboard_event.keyboard.phase != .key_down) return;
|
||||
const target = keyboard_event.target orelse return;
|
||||
const scrolled_id = try self.scrollVirtualWidgetFromKeyboard(runtime, keyboard_event) orelse target.id;
|
||||
try self.reportWidgetInteraction(runtime, keyboard_event.window_id, "Keyed", scrolled_id);
|
||||
}
|
||||
|
||||
/// The engine's dismissal (Escape, outside click, automation) hands
|
||||
/// the surface id back so the MODEL closes it — the app clears the
|
||||
/// open flag and rebuilds, agreeing with the optimistic hide.
|
||||
fn handleWidgetDismiss(self: *@This(), runtime: *native_sdk.Runtime, dismiss_event: native_sdk.runtime.CanvasWidgetDismissEvent) anyerror!void {
|
||||
if (!std.mem.eql(u8, dismiss_event.view_label, canvas_label)) return;
|
||||
if (dismiss_event.id != environment_menu_id) return;
|
||||
if (!self.environment_select_open) return;
|
||||
self.environment_select_open = false;
|
||||
try self.updateComponentsCanvasModel(runtime, dismiss_event.window_id);
|
||||
try self.updateStatus(runtime, dismiss_event.window_id, "Environment menu closed.");
|
||||
}
|
||||
|
||||
fn reportWidgetInteraction(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId, action: []const u8, id: canvas.ObjectId) anyerror!void {
|
||||
const layout = try runtime.canvasWidgetLayout(window_id, canvas_label);
|
||||
const node = layout.findById(id) orelse return;
|
||||
const widget = node.widget;
|
||||
var status_buffer: [192]u8 = undefined;
|
||||
const status = switch (widget.kind) {
|
||||
.checkbox, .radio, .switch_control, .toggle, .toggle_button => try std.fmt.bufPrint(
|
||||
&status_buffer,
|
||||
"{s} {s} #{d}: {s}.",
|
||||
.{ action, @tagName(widget.kind), id, if (widget.state.selected or widget.value >= 0.5) "on" else "off" },
|
||||
),
|
||||
.slider, .progress => try std.fmt.bufPrint(
|
||||
&status_buffer,
|
||||
"{s} {s} #{d}: value {d:.2}.",
|
||||
.{ action, @tagName(widget.kind), id, widget.value },
|
||||
),
|
||||
.scroll_view, .list, .data_grid, .table => try std.fmt.bufPrint(
|
||||
&status_buffer,
|
||||
"{s} {s} #{d}: offset {d}.",
|
||||
.{ action, @tagName(widget.kind), id, widget.value },
|
||||
),
|
||||
.input, .text_field, .search_field, .combobox, .textarea => try std.fmt.bufPrint(
|
||||
&status_buffer,
|
||||
"{s} {s} #{d}: {d} bytes.",
|
||||
.{ action, @tagName(widget.kind), id, widget.text.len },
|
||||
),
|
||||
else => try std.fmt.bufPrint(
|
||||
&status_buffer,
|
||||
"{s} {s} #{d}{s}.",
|
||||
.{ action, @tagName(widget.kind), id, if (widget.state.selected) ": selected" else "" },
|
||||
),
|
||||
};
|
||||
try self.updateStatus(runtime, window_id, status);
|
||||
}
|
||||
|
||||
fn scrollVirtualWidget(self: *@This(), runtime: *native_sdk.Runtime, pointer_event: native_sdk.runtime.CanvasWidgetPointerEvent) anyerror!?canvas.ObjectId {
|
||||
const id = componentVirtualScrollTarget(pointer_event.route) orelse return null;
|
||||
const layout = try runtime.canvasWidgetLayout(pointer_event.window_id, canvas_label);
|
||||
const node = layout.findById(id) orelse return null;
|
||||
if (!node.widget.layout.virtualized) return null;
|
||||
|
||||
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
|
||||
if (viewport.isEmpty()) return null;
|
||||
|
||||
const max_offset = @max(0, canvas.virtualWidgetScrollContentExtent(node.widget, viewport.height) - viewport.height);
|
||||
const current = self.componentVirtualScrollState(id, viewport.height, viewport.height + max_offset) orelse return null;
|
||||
const next = current.applyWheel(pointer_event.pointer.delta.dy, self.componentTokens().scroll);
|
||||
if (componentScrollStatesEqual(current, next)) return id;
|
||||
|
||||
try self.setComponentVirtualScrollState(id, next);
|
||||
try self.updateComponentsCanvasModel(runtime, pointer_event.window_id);
|
||||
return id;
|
||||
}
|
||||
|
||||
fn scrollVirtualWidgetFromKeyboard(self: *@This(), runtime: *native_sdk.Runtime, keyboard_event: native_sdk.runtime.CanvasWidgetKeyboardEvent) anyerror!?canvas.ObjectId {
|
||||
if (keyboard_event.keyboard.modifiers.hasNavigationModifier()) return null;
|
||||
const target = keyboard_event.target orelse return null;
|
||||
const id = componentVirtualScrollTarget(keyboard_event.route) orelse return null;
|
||||
const layout = try runtime.canvasWidgetLayout(keyboard_event.window_id, canvas_label);
|
||||
const node = layout.findById(id) orelse return null;
|
||||
if (!node.widget.layout.virtualized) return null;
|
||||
|
||||
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
|
||||
if (viewport.isEmpty()) return null;
|
||||
|
||||
const direct_target = target.id == id;
|
||||
const max_offset = @max(0, canvas.virtualWidgetScrollContentExtent(node.widget, viewport.height) - viewport.height);
|
||||
const current = self.componentVirtualScrollValue(id) orelse return null;
|
||||
const raw_next = if (componentVirtualKeyboardScrollTarget(keyboard_event.keyboard, direct_target)) |scroll_target| switch (scroll_target) {
|
||||
.start => 0,
|
||||
.end => max_offset,
|
||||
} else if (componentVirtualKeyboardScrollDelta(viewport.height, keyboard_event.keyboard, direct_target)) |delta|
|
||||
std.math.clamp(current + delta, 0, max_offset)
|
||||
else
|
||||
return null;
|
||||
const next = snapComponentVirtualScrollOffset(node.widget, current, raw_next, max_offset);
|
||||
if (next == current) return id;
|
||||
|
||||
try self.setComponentVirtualScrollState(id, .{
|
||||
.offset = next,
|
||||
.velocity = 0,
|
||||
.viewport_extent = viewport.height,
|
||||
.content_extent = viewport.height + max_offset,
|
||||
});
|
||||
try self.updateComponentsCanvasModel(runtime, keyboard_event.window_id);
|
||||
return id;
|
||||
}
|
||||
|
||||
fn stepComponentVirtualScrollForFrame(self: *@This(), runtime: *native_sdk.Runtime, frame_event: native_sdk.GpuSurfaceFrameEvent) anyerror!bool {
|
||||
const layout = try runtime.canvasWidgetLayout(frame_event.window_id, canvas_label);
|
||||
var changed = false;
|
||||
const ids = [_]canvas.ObjectId{ 120, 130, 150 };
|
||||
for (ids) |id| {
|
||||
const node = layout.findById(id) orelse continue;
|
||||
if (!node.widget.layout.virtualized) continue;
|
||||
const viewport = node.frame.inset(node.widget.layout.padding).normalized();
|
||||
if (viewport.isEmpty()) continue;
|
||||
|
||||
const content_extent = canvas.virtualWidgetScrollContentExtent(node.widget, viewport.height);
|
||||
const current = self.componentVirtualScrollState(id, viewport.height, content_extent) orelse continue;
|
||||
if (!current.needsKineticStep(self.componentTokens().scroll)) {
|
||||
if (current.velocity != 0) {
|
||||
var settled = current;
|
||||
settled.velocity = 0;
|
||||
try self.setComponentVirtualScrollState(id, settled);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = current.stepKinetic(componentFrameIntervalMs(frame_event.frame_interval_ns), self.componentTokens().scroll);
|
||||
if (componentScrollStatesEqual(current, next)) continue;
|
||||
try self.setComponentVirtualScrollState(id, next);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) try self.updateComponentsCanvasModel(runtime, frame_event.window_id);
|
||||
return changed;
|
||||
}
|
||||
|
||||
fn refresh(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent) anyerror!void {
|
||||
self.refresh_count += 1;
|
||||
self.virtual_scroll = .{};
|
||||
self.environment_select_open = false;
|
||||
self.surface_overlay = .none;
|
||||
self.section = .controls;
|
||||
_ = runtime.clearCanvasRenderAnimations(command.window_id, canvas_label) catch {};
|
||||
const gpu_frame = try runtime.gpuSurfaceFrame(command.window_id, canvas_label);
|
||||
_ = self.updateCanvasSize(componentSurfaceSize(gpu_frame.size));
|
||||
try installComponentsCanvasModel(runtime, command.window_id, self.virtual_scroll, self.componentUiState(), self.componentTokens(), self.canvas_size);
|
||||
_ = try self.presentComponentsCanvas(runtime, gpuFrameEvent(gpu_frame), true);
|
||||
|
||||
var status_buffer: [160]u8 = undefined;
|
||||
const status = try std.fmt.bufPrint(&status_buffer, "Component lab refreshed from {s}. Count {d}.", .{ @tagName(command.source), self.refresh_count });
|
||||
try self.updateStatus(runtime, command.window_id, status);
|
||||
}
|
||||
|
||||
fn changeSection(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent, section: ComponentSection) anyerror!void {
|
||||
self.section = section;
|
||||
self.environment_select_open = false;
|
||||
self.surface_overlay = .none;
|
||||
self.virtual_scroll.page = 0;
|
||||
_ = runtime.clearCanvasRenderAnimations(command.window_id, canvas_label) catch {};
|
||||
try self.updateComponentsCanvasModel(runtime, command.window_id);
|
||||
|
||||
var status_buffer: [96]u8 = undefined;
|
||||
const status = try std.fmt.bufPrint(&status_buffer, "Showing {s}.", .{componentSectionLabel(section)});
|
||||
try self.updateStatus(runtime, command.window_id, status);
|
||||
}
|
||||
|
||||
fn toggleEnvironmentSelect(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent) anyerror!void {
|
||||
self.environment_select_open = !self.environment_select_open;
|
||||
try self.updateComponentsCanvasModel(runtime, command.window_id);
|
||||
try self.updateStatus(runtime, command.window_id, if (self.environment_select_open) "Environment menu opened." else "Environment menu closed.");
|
||||
}
|
||||
|
||||
fn selectEnvironment(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent, index: usize) anyerror!void {
|
||||
self.environment_index = @min(index, environment_options.len - 1);
|
||||
self.environment_select_open = false;
|
||||
try self.updateComponentsCanvasModel(runtime, command.window_id);
|
||||
try self.updateEnvironmentSelectedStatus(runtime, command.window_id);
|
||||
}
|
||||
|
||||
fn updateEnvironmentSelectedStatus(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId) anyerror!void {
|
||||
var status_buffer: [96]u8 = undefined;
|
||||
const status = try std.fmt.bufPrint(&status_buffer, "Environment selected: {s}.", .{environmentLabel(self.environment_index)});
|
||||
try self.updateStatus(runtime, window_id, status);
|
||||
}
|
||||
|
||||
fn openSurfaceOverlay(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent, overlay: ComponentSurfaceOverlay) anyerror!void {
|
||||
self.environment_select_open = false;
|
||||
self.surface_overlay = overlay;
|
||||
try self.updateComponentsCanvasModel(runtime, command.window_id);
|
||||
try self.scheduleSurfaceOverlayAnimation(runtime, command.window_id, overlay);
|
||||
|
||||
var status_buffer: [96]u8 = undefined;
|
||||
const status = try std.fmt.bufPrint(&status_buffer, "{s} surface opened.", .{surfaceOverlayLabel(overlay)});
|
||||
try self.updateStatus(runtime, command.window_id, status);
|
||||
}
|
||||
|
||||
fn closeSurfaceOverlay(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent) anyerror!void {
|
||||
if (self.surface_overlay == .none) return;
|
||||
self.surface_overlay = .none;
|
||||
_ = runtime.clearCanvasRenderAnimations(command.window_id, canvas_label) catch {};
|
||||
try self.updateComponentsCanvasModel(runtime, command.window_id);
|
||||
try self.updateStatus(runtime, command.window_id, "Surface closed.");
|
||||
}
|
||||
|
||||
fn scheduleSurfaceOverlayAnimation(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId, overlay: ComponentSurfaceOverlay) anyerror!void {
|
||||
const motion = self.componentTokens().motion;
|
||||
|
||||
const start_ns = runtime.canvasRenderAnimationStartNs(window_id, canvas_label) catch |err| switch (err) {
|
||||
error.WindowNotFound, error.ViewNotFound, error.InvalidViewOptions => return,
|
||||
else => return err,
|
||||
};
|
||||
var animations: [max_surface_overlay_animations]canvas.CanvasRenderAnimation = undefined;
|
||||
var count: usize = 0;
|
||||
try canvas.appendBuiltinSurfaceEnterAnimations(surfaceOverlayKind(overlay), .{
|
||||
.surface_id = surface_overlay_id,
|
||||
.frame = surfaceOverlayFrameForSidebar(self.canvas_size, overlay, self.sidebar_width),
|
||||
.motion = motion,
|
||||
.start_ns = start_ns,
|
||||
.content = &surface_overlay_content_parts,
|
||||
}, &animations, &count);
|
||||
if (count == 0) return;
|
||||
_ = try runtime.setCanvasRenderAnimations(window_id, canvas_label, animations[0..count]);
|
||||
}
|
||||
|
||||
fn changeTheme(self: *@This(), runtime: *native_sdk.Runtime, command: native_sdk.CommandEvent, mode: ComponentThemeMode) anyerror!void {
|
||||
self.theme_count += 1;
|
||||
self.theme_overridden = true;
|
||||
self.theme_mode = mode;
|
||||
const gpu_frame = try runtime.gpuSurfaceFrame(command.window_id, canvas_label);
|
||||
_ = self.updateCanvasSize(componentSurfaceSize(gpu_frame.size));
|
||||
try installComponentsCanvasModel(runtime, command.window_id, self.virtual_scroll, self.componentUiState(), self.componentTokens(), self.canvas_size);
|
||||
_ = try self.presentComponentsCanvas(runtime, gpuFrameEvent(gpu_frame), true);
|
||||
|
||||
var status_buffer: [160]u8 = undefined;
|
||||
const status = try std.fmt.bufPrint(
|
||||
&status_buffer,
|
||||
"GPU component theme: {s} from {s}. Count {d}.",
|
||||
.{ self.theme_mode.label(), @tagName(command.source), self.theme_count },
|
||||
);
|
||||
try self.updateStatus(runtime, command.window_id, status);
|
||||
}
|
||||
|
||||
fn applySystemAppearance(self: *@This(), runtime: *native_sdk.Runtime, appearance: native_sdk.Appearance) anyerror!void {
|
||||
const motion_changed = self.reduce_motion != appearance.reduce_motion;
|
||||
const contrast_changed = self.high_contrast != appearance.high_contrast;
|
||||
self.reduce_motion = appearance.reduce_motion;
|
||||
self.high_contrast = appearance.high_contrast;
|
||||
const next = componentThemeModeForAppearance(appearance);
|
||||
const theme_changed = !self.theme_overridden and self.theme_mode != next;
|
||||
if (theme_changed) self.theme_mode = next;
|
||||
if (!theme_changed and !motion_changed and !contrast_changed) return;
|
||||
if (!self.canvas_installed) return;
|
||||
|
||||
const gpu_frame = runtime.gpuSurfaceFrame(1, canvas_label) catch |err| switch (err) {
|
||||
error.WindowNotFound, error.ViewNotFound, error.InvalidViewOptions => return,
|
||||
else => return err,
|
||||
};
|
||||
_ = self.updateCanvasSize(componentSurfaceSize(gpu_frame.size));
|
||||
try installComponentsCanvasModel(runtime, 1, self.virtual_scroll, self.componentUiState(), self.componentTokens(), self.canvas_size);
|
||||
_ = try self.presentComponentsCanvas(runtime, gpuFrameEvent(gpu_frame), true);
|
||||
|
||||
var status_buffer: [160]u8 = undefined;
|
||||
const status = try std.fmt.bufPrint(&status_buffer, "GPU component theme: {s} from system appearance.", .{self.theme_mode.label()});
|
||||
try self.updateStatus(runtime, 1, status);
|
||||
}
|
||||
|
||||
fn presentComponentsCanvas(self: *@This(), runtime: *native_sdk.Runtime, frame_event: native_sdk.GpuSurfaceFrameEvent, full_repaint: bool) anyerror!void {
|
||||
const surface_size = componentSurfaceSize(frame_event.size);
|
||||
const scale_factor = if (frame_event.scale_factor > 0) frame_event.scale_factor else 1;
|
||||
const present_scale = referencePresentScale(scale_factor);
|
||||
const packet = runtime.presentNextCanvasGpuPacketWithScale(
|
||||
frame_event.window_id,
|
||||
canvas_label,
|
||||
.{
|
||||
.frame_index = frame_event.frame_index,
|
||||
.timestamp_ns = frame_event.timestamp_ns,
|
||||
.surface_size = surface_size,
|
||||
.scale = scale_factor,
|
||||
.full_repaint = full_repaint,
|
||||
.image_resources = &preview_images,
|
||||
},
|
||||
self.frameStorage(),
|
||||
self.componentTokens().colors.background,
|
||||
&self.gpu_commands,
|
||||
&self.packet_json,
|
||||
present_scale,
|
||||
) catch |err| switch (err) {
|
||||
error.UnsupportedService => {
|
||||
try self.presentComponentsCanvasPixels(runtime, frame_event.window_id, surface_size, scale_factor, frame_event.frame_index, frame_event.timestamp_ns, full_repaint);
|
||||
return;
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
if (!packet.fullyRepresentable()) return error.UnsupportedCommand;
|
||||
}
|
||||
|
||||
fn presentComponentsCanvasPixels(
|
||||
self: *@This(),
|
||||
runtime: *native_sdk.Runtime,
|
||||
window_id: native_sdk.WindowId,
|
||||
surface_size: geometry.SizeF,
|
||||
scale_factor: f32,
|
||||
frame_index: u64,
|
||||
timestamp_ns: u64,
|
||||
full_repaint: bool,
|
||||
) anyerror!void {
|
||||
const present_scale = referencePresentScale(scale_factor);
|
||||
try self.ensurePixelBuffers(surface_size, present_scale);
|
||||
_ = try runtime.presentNextCanvasFrame(
|
||||
window_id,
|
||||
canvas_label,
|
||||
.{
|
||||
.frame_index = frame_index,
|
||||
.timestamp_ns = timestamp_ns,
|
||||
.surface_size = surface_size,
|
||||
.scale = scale_factor,
|
||||
.full_repaint = full_repaint,
|
||||
.image_resources = &preview_images,
|
||||
},
|
||||
self.frameStorage(),
|
||||
&self.gpu_commands,
|
||||
&self.packet_json,
|
||||
self.pixels.?,
|
||||
self.scratch.?,
|
||||
self.componentTokens().colors.background,
|
||||
present_scale,
|
||||
);
|
||||
}
|
||||
|
||||
fn referencePresentScale(scale_factor: f32) f32 {
|
||||
const normalized = if (scale_factor > 0) scale_factor else 1;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
pub fn setStatusText(self: *@This(), text: []const u8) void {
|
||||
const len = @min(text.len, self.status_text_storage.len);
|
||||
@memcpy(self.status_text_storage[0..len], text[0..len]);
|
||||
self.status_text_len = len;
|
||||
}
|
||||
|
||||
fn statusText(self: *const @This()) []const u8 {
|
||||
if (self.status_text_len == 0) return initial_component_status_text;
|
||||
return self.status_text_storage[0..self.status_text_len];
|
||||
}
|
||||
|
||||
fn updateStatus(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId, text: []const u8) anyerror!void {
|
||||
self.setStatusText(text);
|
||||
if (self.canvas_installed) try self.updateComponentsCanvasModel(runtime, window_id);
|
||||
}
|
||||
|
||||
fn reportFrameStatus(self: *@This(), runtime: *native_sdk.Runtime, frame_event: native_sdk.GpuSurfaceFrameEvent) anyerror!void {
|
||||
if (self.reported_planned_frame or frame_event.canvas_command_count == 0) return;
|
||||
self.reported_planned_frame = true;
|
||||
var status_buffer: [160]u8 = undefined;
|
||||
const status = try componentFrameStatus(&status_buffer, frame_event);
|
||||
try self.updateStatus(runtime, frame_event.window_id, status);
|
||||
}
|
||||
|
||||
fn frameStorage(self: *@This()) canvas.CanvasFrameStorage {
|
||||
return .{
|
||||
.render_commands = &self.render_commands,
|
||||
.render_batches = &self.render_batches,
|
||||
.images = &self.images,
|
||||
.image_cache_entries = &self.image_cache_entries,
|
||||
.image_cache_actions = &self.image_cache_actions,
|
||||
.pipeline_cache_entries = &self.pipeline_cache_entries,
|
||||
.pipeline_cache_actions = &self.pipeline_cache_actions,
|
||||
.layers = &self.layers,
|
||||
.layer_cache_entries = &self.layer_cache_entries,
|
||||
.layer_cache_actions = &self.layer_cache_actions,
|
||||
.resources = &self.resources,
|
||||
.resource_cache_entries = &self.cache_entries,
|
||||
.resource_cache_actions = &self.cache_actions,
|
||||
.visual_effects = &self.visual_effects,
|
||||
.visual_effect_cache_entries = &self.visual_effect_cache_entries,
|
||||
.visual_effect_cache_actions = &self.visual_effect_cache_actions,
|
||||
.glyph_atlas_entries = &self.glyphs,
|
||||
.glyph_atlas_cache_entries = &self.glyph_cache_entries,
|
||||
.glyph_atlas_cache_actions = &self.glyph_cache_actions,
|
||||
.text_layout_plans = &self.text_layout_plans,
|
||||
.text_layout_lines = &self.text_layout_lines,
|
||||
.text_layout_cache_entries = &self.text_layout_cache_entries,
|
||||
.text_layout_cache_actions = &self.text_layout_cache_actions,
|
||||
.changes = &self.changes,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn updateComponentsCanvasModel(self: *@This(), runtime: *native_sdk.Runtime, window_id: native_sdk.WindowId) anyerror!void {
|
||||
var nodes: [max_component_widgets]canvas.WidgetLayoutNode = undefined;
|
||||
// Layout under the SAME tokens the display list is emitted
|
||||
// with: under geometry pixel snapping, label-hugging intrinsic
|
||||
// widths ceil to the snap grid, and only a token-matched layout
|
||||
// keeps the renderer's edge snapping from shaving those widths
|
||||
// below their labels (eliding text that fits unsnapped).
|
||||
const layout = try buildComponentsWidgetLayoutWithStateSizeAndTokens(&nodes, self.virtual_scroll, self.componentUiState(), self.canvas_size, self.componentTokens());
|
||||
_ = try runtime.setCanvasWidgetLayout(window_id, canvas_label, layout);
|
||||
_ = try runtime.emitCanvasWidgetDisplayListWithStoredTokensAndChrome(window_id, canvas_label, .{
|
||||
.prefix_command_count = component_chrome_prefix_commands,
|
||||
.suffix_command_count = component_chrome_suffix_commands,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn componentUiState(self: *const @This()) ComponentUiState {
|
||||
return .{
|
||||
.theme_mode = self.theme_mode,
|
||||
.environment_select_open = self.environment_select_open,
|
||||
.environment_index = self.environment_index,
|
||||
.surface_overlay = self.surface_overlay,
|
||||
.section = self.section,
|
||||
.sidebar_width = self.sidebar_width,
|
||||
.status_text = self.statusText(),
|
||||
};
|
||||
}
|
||||
|
||||
fn componentTokens(self: *const @This()) canvas.DesignTokens {
|
||||
return componentTokensForScaleMotionAndContrast(self.theme_mode, self.pixel_snap_scale, self.reduce_motion, self.high_contrast);
|
||||
}
|
||||
|
||||
fn updatePixelSnapScale(self: *@This(), scale_factor: f32) bool {
|
||||
const next = normalizedPixelSnapScale(scale_factor);
|
||||
if (@abs(self.pixel_snap_scale - next) < 0.001) return false;
|
||||
self.pixel_snap_scale = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
fn updateCanvasSize(self: *@This(), size: geometry.SizeF) bool {
|
||||
const next_sidebar_width = componentSidebarWidthForSize(self.sidebar_width, size);
|
||||
const sidebar_changed = @abs(next_sidebar_width - self.sidebar_width) >= 0.001;
|
||||
if (sidebar_changed) self.sidebar_width = next_sidebar_width;
|
||||
if (componentSizesEqual(self.canvas_size, size)) return sidebar_changed;
|
||||
self.canvas_size = size;
|
||||
return true;
|
||||
}
|
||||
|
||||
fn componentVirtualScrollValue(self: *@This(), id: canvas.ObjectId) ?f32 {
|
||||
return switch (id) {
|
||||
120 => self.virtual_scroll.nav,
|
||||
130 => self.virtual_scroll.behavior,
|
||||
150 => self.virtual_scroll.data,
|
||||
content_scroll_id => self.virtual_scroll.page,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn componentVirtualScrollVelocity(self: *@This(), id: canvas.ObjectId) ?f32 {
|
||||
return switch (id) {
|
||||
120 => self.virtual_scroll.nav_velocity,
|
||||
130 => self.virtual_scroll.behavior_velocity,
|
||||
150 => self.virtual_scroll.data_velocity,
|
||||
content_scroll_id => self.virtual_scroll.page_velocity,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn componentVirtualScrollState(self: *@This(), id: canvas.ObjectId, viewport_extent: f32, content_extent: f32) ?canvas.ScrollAxisState {
|
||||
const offset = self.componentVirtualScrollValue(id) orelse return null;
|
||||
const velocity = self.componentVirtualScrollVelocity(id) orelse return null;
|
||||
return .{
|
||||
.offset = offset,
|
||||
.velocity = velocity,
|
||||
.viewport_extent = viewport_extent,
|
||||
.content_extent = @max(viewport_extent, content_extent),
|
||||
};
|
||||
}
|
||||
|
||||
fn setComponentVirtualScrollValue(self: *@This(), id: canvas.ObjectId, value: f32) anyerror!void {
|
||||
switch (id) {
|
||||
120 => {
|
||||
self.virtual_scroll.nav = value;
|
||||
self.virtual_scroll.nav_velocity = 0;
|
||||
},
|
||||
130 => {
|
||||
self.virtual_scroll.behavior = value;
|
||||
self.virtual_scroll.behavior_velocity = 0;
|
||||
},
|
||||
150 => {
|
||||
self.virtual_scroll.data = value;
|
||||
self.virtual_scroll.data_velocity = 0;
|
||||
},
|
||||
content_scroll_id => {
|
||||
self.virtual_scroll.page = value;
|
||||
self.virtual_scroll.page_velocity = 0;
|
||||
},
|
||||
else => return error.InvalidCommand,
|
||||
}
|
||||
}
|
||||
|
||||
fn setComponentVirtualScrollState(self: *@This(), id: canvas.ObjectId, state: canvas.ScrollAxisState) anyerror!void {
|
||||
switch (id) {
|
||||
120 => {
|
||||
self.virtual_scroll.nav = state.offset;
|
||||
self.virtual_scroll.nav_velocity = state.velocity;
|
||||
},
|
||||
130 => {
|
||||
self.virtual_scroll.behavior = state.offset;
|
||||
self.virtual_scroll.behavior_velocity = state.velocity;
|
||||
},
|
||||
150 => {
|
||||
self.virtual_scroll.data = state.offset;
|
||||
self.virtual_scroll.data_velocity = state.velocity;
|
||||
},
|
||||
content_scroll_id => {
|
||||
self.virtual_scroll.page = state.offset;
|
||||
self.virtual_scroll.page_velocity = state.velocity;
|
||||
},
|
||||
else => return error.InvalidCommand,
|
||||
}
|
||||
}
|
||||
|
||||
fn ensurePixelBuffers(self: *@This(), surface_size: geometry.SizeF, scale_factor: f32) anyerror!void {
|
||||
const pixel_size = try native_sdk.runtime.canvasSurfacePixelSize(surface_size, scale_factor);
|
||||
if (self.pixels == null or self.pixels.?.len < pixel_size.byte_len) {
|
||||
if (self.pixels) |pixels| std.heap.page_allocator.free(pixels);
|
||||
self.pixels = try std.heap.page_allocator.alloc(u8, pixel_size.byte_len);
|
||||
}
|
||||
if (self.scratch == null or self.scratch.?.len < pixel_size.byte_len) {
|
||||
if (self.scratch) |scratch| std.heap.page_allocator.free(scratch);
|
||||
self.scratch = try std.heap.page_allocator.alloc(u8, pixel_size.byte_len);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,463 @@
|
||||
// gpu-components: the state tier for the TypeScript + Native markup
|
||||
// component gallery. The markup owns the complete view; this core only owns
|
||||
// controlled component state and the messages produced by interaction.
|
||||
|
||||
import { Cmd, asciiBytes } from "@native-sdk/core";
|
||||
import {
|
||||
applyTextInputEvent,
|
||||
clampedInsertEvent,
|
||||
type TextEditState,
|
||||
type TextInputEvent,
|
||||
} from "@native-sdk/core/text";
|
||||
|
||||
const MAX_TEXT_BYTES = 160;
|
||||
|
||||
export interface ComponentItem {
|
||||
readonly id: number;
|
||||
readonly label: Uint8Array;
|
||||
}
|
||||
|
||||
const COMPONENTS: readonly ComponentItem[] = [
|
||||
{ id: 1, label: asciiBytes("Accordion") },
|
||||
{ id: 2, label: asciiBytes("Alert") },
|
||||
{ id: 3, label: asciiBytes("Avatar") },
|
||||
{ id: 4, label: asciiBytes("Badge") },
|
||||
{ id: 5, label: asciiBytes("Breadcrumb") },
|
||||
{ id: 6, label: asciiBytes("Bubble") },
|
||||
{ id: 7, label: asciiBytes("Button") },
|
||||
{ id: 8, label: asciiBytes("Button Group") },
|
||||
{ id: 9, label: asciiBytes("Card") },
|
||||
{ id: 10, label: asciiBytes("Checkbox") },
|
||||
{ id: 11, label: asciiBytes("Combobox") },
|
||||
{ id: 12, label: asciiBytes("Dialog") },
|
||||
{ id: 13, label: asciiBytes("Drawer") },
|
||||
{ id: 14, label: asciiBytes("Dropdown Menu") },
|
||||
{ id: 15, label: asciiBytes("Input") },
|
||||
{ id: 16, label: asciiBytes("Pagination") },
|
||||
{ id: 17, label: asciiBytes("Progress") },
|
||||
{ id: 18, label: asciiBytes("Radio Group") },
|
||||
{ id: 19, label: asciiBytes("Resizable") },
|
||||
{ id: 20, label: asciiBytes("Select") },
|
||||
{ id: 21, label: asciiBytes("Separator") },
|
||||
{ id: 22, label: asciiBytes("Sheet") },
|
||||
{ id: 23, label: asciiBytes("Skeleton") },
|
||||
{ id: 24, label: asciiBytes("Slider") },
|
||||
{ id: 25, label: asciiBytes("Spinner") },
|
||||
{ id: 26, label: asciiBytes("Switch") },
|
||||
{ id: 27, label: asciiBytes("Table") },
|
||||
{ id: 28, label: asciiBytes("Tabs") },
|
||||
{ id: 29, label: asciiBytes("Textarea") },
|
||||
{ id: 30, label: asciiBytes("Toggle") },
|
||||
{ id: 31, label: asciiBytes("Toggle Group") },
|
||||
{ id: 32, label: asciiBytes("Tooltip") },
|
||||
{ id: 33, label: asciiBytes("List") },
|
||||
{ id: 34, label: asciiBytes("Tree") },
|
||||
];
|
||||
|
||||
export interface Draft {
|
||||
readonly bytes: Uint8Array;
|
||||
readonly anchor: number;
|
||||
readonly focus: number;
|
||||
readonly compStart: number;
|
||||
readonly compEnd: number;
|
||||
}
|
||||
|
||||
function draft(text: string): Draft {
|
||||
const bytes = asciiBytes(text);
|
||||
const length = bytes.length;
|
||||
const cursor = length >= 0 && length <= 9007199254740991 ? Math.trunc(length) : 0;
|
||||
return {
|
||||
bytes: bytes,
|
||||
anchor: cursor,
|
||||
focus: cursor,
|
||||
compStart: -1,
|
||||
compEnd: -1,
|
||||
};
|
||||
}
|
||||
|
||||
function draftState(value: Draft): TextEditState {
|
||||
return {
|
||||
text: value.bytes,
|
||||
selection: { anchor: value.anchor, focus: value.focus },
|
||||
composition: value.compStart >= 0
|
||||
? { start: value.compStart, end: value.compEnd }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function draftFromState(value: TextEditState): Draft {
|
||||
const start = value.composition !== null ? value.composition.start : -1;
|
||||
const end = value.composition !== null ? value.composition.end : -1;
|
||||
return {
|
||||
bytes: value.text,
|
||||
anchor: value.selection.anchor,
|
||||
focus: value.selection.focus,
|
||||
compStart: start >= -1 && start <= 9007199254740991 ? Math.trunc(start) : -1,
|
||||
compEnd: end >= -1 && end <= 9007199254740991 ? Math.trunc(end) : -1,
|
||||
};
|
||||
}
|
||||
|
||||
function applyDraft(value: Draft, event: TextInputEvent): Draft {
|
||||
const state = draftState(value);
|
||||
const next = applyTextInputEvent(state, event, MAX_TEXT_BYTES);
|
||||
if (next !== null) return draftFromState(next);
|
||||
const clamped = clampedInsertEvent(state, event, MAX_TEXT_BYTES);
|
||||
if (clamped === null) return value;
|
||||
const nextClamped = applyTextInputEvent(state, clamped, MAX_TEXT_BYTES);
|
||||
return nextClamped === null ? value : draftFromState(nextClamped);
|
||||
}
|
||||
|
||||
export type Density = "default" | "comfortable";
|
||||
export type ThemePack = "house" | "geist";
|
||||
export type DropdownChoice = "none" | "duplicate" | "rename" | "download" | "delete";
|
||||
export type SelectChoice = "production" | "staging" | "development";
|
||||
export type Tab = "account" | "password" | "team";
|
||||
export type Alignment = "left" | "center" | "right";
|
||||
export type ListSelection = "report" | "checklist" | "archive";
|
||||
export type TreeSelection = "src" | "main" | "view" | "assets" | "logo";
|
||||
|
||||
export interface Model {
|
||||
readonly components: readonly ComponentItem[];
|
||||
readonly selectedComponentId: number;
|
||||
readonly theme: ThemePack;
|
||||
readonly catalogExpanded: boolean;
|
||||
readonly accordionOpen: boolean;
|
||||
readonly checkboxChecked: boolean;
|
||||
readonly usageReportsChecked: boolean;
|
||||
readonly comboboxOpen: boolean;
|
||||
readonly comboboxDraft: Draft;
|
||||
readonly dialogOpen: boolean;
|
||||
readonly drawerOpen: boolean;
|
||||
readonly onlyUnreadChecked: boolean;
|
||||
readonly hasAttachmentsChecked: boolean;
|
||||
readonly compactRows: boolean;
|
||||
readonly dropdownOpen: boolean;
|
||||
readonly dropdownChoice: DropdownChoice;
|
||||
readonly inputDraft: Draft;
|
||||
readonly page: number;
|
||||
readonly density: Density;
|
||||
readonly selectOpen: boolean;
|
||||
readonly selectChoice: SelectChoice;
|
||||
readonly sheetOpen: boolean;
|
||||
readonly sliderValue: number;
|
||||
readonly notificationsEnabled: boolean;
|
||||
readonly airplaneMode: boolean;
|
||||
readonly tab: Tab;
|
||||
readonly textareaDraft: Draft;
|
||||
readonly bold: boolean;
|
||||
readonly italic: boolean;
|
||||
readonly alignment: Alignment;
|
||||
readonly listSelection: ListSelection;
|
||||
readonly srcExpanded: boolean;
|
||||
readonly assetsExpanded: boolean;
|
||||
readonly treeSelection: TreeSelection;
|
||||
}
|
||||
|
||||
export type Msg =
|
||||
| { readonly kind: "select_component"; readonly componentId: number }
|
||||
| { readonly kind: "theme_house" }
|
||||
| { readonly kind: "theme_geist" }
|
||||
| { readonly kind: "toggle_catalog" }
|
||||
| { readonly kind: "action" }
|
||||
| { readonly kind: "toggle_accordion" }
|
||||
| { readonly kind: "toggle_checkbox" }
|
||||
| { readonly kind: "toggle_usage_reports" }
|
||||
| { readonly kind: "combobox_edited"; readonly edit: TextInputEvent }
|
||||
| { readonly kind: "open_combobox" }
|
||||
| { readonly kind: "close_combobox" }
|
||||
| { readonly kind: "choose_native" }
|
||||
| { readonly kind: "choose_canvas" }
|
||||
| { readonly kind: "open_dialog" }
|
||||
| { readonly kind: "close_dialog" }
|
||||
| { readonly kind: "open_drawer" }
|
||||
| { readonly kind: "close_drawer" }
|
||||
| { readonly kind: "toggle_only_unread" }
|
||||
| { readonly kind: "toggle_has_attachments" }
|
||||
| { readonly kind: "toggle_compact_rows" }
|
||||
| { readonly kind: "toggle_dropdown" }
|
||||
| { readonly kind: "close_dropdown" }
|
||||
| { readonly kind: "dropdown_duplicate" }
|
||||
| { readonly kind: "dropdown_rename" }
|
||||
| { readonly kind: "dropdown_download" }
|
||||
| { readonly kind: "dropdown_delete" }
|
||||
| { readonly kind: "input_edited"; readonly edit: TextInputEvent }
|
||||
| { readonly kind: "previous_page" }
|
||||
| { readonly kind: "next_page" }
|
||||
| { readonly kind: "page_one" }
|
||||
| { readonly kind: "page_two" }
|
||||
| { readonly kind: "page_three" }
|
||||
| { readonly kind: "density_default" }
|
||||
| { readonly kind: "density_comfortable" }
|
||||
| { readonly kind: "toggle_select" }
|
||||
| { readonly kind: "close_select" }
|
||||
| { readonly kind: "select_production" }
|
||||
| { readonly kind: "select_staging" }
|
||||
| { readonly kind: "select_development" }
|
||||
| { readonly kind: "open_sheet" }
|
||||
| { readonly kind: "close_sheet" }
|
||||
| { readonly kind: "slider_changed"; readonly fraction: number }
|
||||
| { readonly kind: "toggle_notifications" }
|
||||
| { readonly kind: "toggle_airplane_mode" }
|
||||
| { readonly kind: "tab_account" }
|
||||
| { readonly kind: "tab_password" }
|
||||
| { readonly kind: "tab_team" }
|
||||
| { readonly kind: "textarea_edited"; readonly edit: TextInputEvent }
|
||||
| { readonly kind: "toggle_bold" }
|
||||
| { readonly kind: "toggle_italic" }
|
||||
| { readonly kind: "align_left" }
|
||||
| { readonly kind: "align_center" }
|
||||
| { readonly kind: "align_right" }
|
||||
| { readonly kind: "list_report" }
|
||||
| { readonly kind: "list_checklist" }
|
||||
| { readonly kind: "list_archive" }
|
||||
| { readonly kind: "toggle_src" }
|
||||
| { readonly kind: "toggle_assets" }
|
||||
| { readonly kind: "tree_src" }
|
||||
| { readonly kind: "tree_main" }
|
||||
| { readonly kind: "tree_view" }
|
||||
| { readonly kind: "tree_assets" }
|
||||
| { readonly kind: "tree_logo" };
|
||||
|
||||
// These records are intentionally read only through binding helpers.
|
||||
export const viewUnbound = [
|
||||
"theme",
|
||||
"comboboxDraft",
|
||||
"inputDraft",
|
||||
"textareaDraft",
|
||||
"selectChoice",
|
||||
] as const;
|
||||
|
||||
export function initialModel(): Model {
|
||||
return {
|
||||
components: COMPONENTS,
|
||||
selectedComponentId: 1,
|
||||
theme: "house",
|
||||
catalogExpanded: true,
|
||||
accordionOpen: true,
|
||||
checkboxChecked: true,
|
||||
usageReportsChecked: false,
|
||||
comboboxOpen: false,
|
||||
comboboxDraft: draft(""),
|
||||
dialogOpen: false,
|
||||
drawerOpen: false,
|
||||
onlyUnreadChecked: true,
|
||||
hasAttachmentsChecked: false,
|
||||
compactRows: true,
|
||||
dropdownOpen: false,
|
||||
dropdownChoice: "none",
|
||||
inputDraft: draft("native-sdk"),
|
||||
page: 1,
|
||||
density: "default",
|
||||
selectOpen: false,
|
||||
selectChoice: "production",
|
||||
sheetOpen: false,
|
||||
sliderValue: 0.64,
|
||||
notificationsEnabled: true,
|
||||
airplaneMode: false,
|
||||
tab: "account",
|
||||
textareaDraft: draft("TypeScript state, Native markup view."),
|
||||
bold: true,
|
||||
italic: false,
|
||||
alignment: "left",
|
||||
listSelection: "report",
|
||||
srcExpanded: true,
|
||||
assetsExpanded: false,
|
||||
treeSelection: "main",
|
||||
};
|
||||
}
|
||||
|
||||
export function comboboxText(model: Model): Uint8Array {
|
||||
return model.comboboxDraft.bytes;
|
||||
}
|
||||
|
||||
export function inputText(model: Model): Uint8Array {
|
||||
return model.inputDraft.bytes;
|
||||
}
|
||||
|
||||
export function textareaText(model: Model): Uint8Array {
|
||||
return model.textareaDraft.bytes;
|
||||
}
|
||||
|
||||
export function selectedLabel(model: Model): Uint8Array {
|
||||
for (const component of model.components) {
|
||||
if (component.id === model.selectedComponentId) return component.label;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
export function selectLabel(model: Model): Uint8Array {
|
||||
switch (model.selectChoice) {
|
||||
case "production": return asciiBytes("Production");
|
||||
case "staging": return asciiBytes("Staging");
|
||||
case "development": return asciiBytes("Development");
|
||||
}
|
||||
}
|
||||
|
||||
export function dropdownStatus(model: Model): Uint8Array {
|
||||
switch (model.dropdownChoice) {
|
||||
case "none": return asciiBytes("Last action: None");
|
||||
case "duplicate": return asciiBytes("Last action: Duplicate");
|
||||
case "rename": return asciiBytes("Last action: Rename");
|
||||
case "download": return asciiBytes("Last action: Download");
|
||||
case "delete": return asciiBytes("Last action: Delete");
|
||||
}
|
||||
}
|
||||
|
||||
function closeTransientSurfaces(model: Model): Model {
|
||||
return {
|
||||
...model,
|
||||
comboboxOpen: false,
|
||||
dialogOpen: false,
|
||||
drawerOpen: false,
|
||||
dropdownOpen: false,
|
||||
selectOpen: false,
|
||||
sheetOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function update(model: Model, msg: Msg): [Model, Cmd<Msg>] {
|
||||
switch (msg.kind) {
|
||||
case "select_component": {
|
||||
if (!(msg.componentId >= 1 && msg.componentId <= COMPONENTS.length)) {
|
||||
return [model, Cmd.none];
|
||||
}
|
||||
const closed = closeTransientSurfaces(model);
|
||||
return [
|
||||
{ ...closed, selectedComponentId: Math.trunc(msg.componentId) },
|
||||
Cmd.none,
|
||||
];
|
||||
}
|
||||
case "theme_house":
|
||||
return [{ ...model, theme: "house" }, Cmd.none];
|
||||
case "theme_geist":
|
||||
return [{ ...model, theme: "geist" }, Cmd.none];
|
||||
case "toggle_catalog":
|
||||
return [{ ...model, catalogExpanded: !model.catalogExpanded }, Cmd.none];
|
||||
case "action":
|
||||
return [model, Cmd.none];
|
||||
case "toggle_accordion":
|
||||
return [{ ...model, accordionOpen: !model.accordionOpen }, Cmd.none];
|
||||
case "toggle_checkbox":
|
||||
return [{ ...model, checkboxChecked: !model.checkboxChecked }, Cmd.none];
|
||||
case "toggle_usage_reports":
|
||||
return [{ ...model, usageReportsChecked: !model.usageReportsChecked }, Cmd.none];
|
||||
case "combobox_edited":
|
||||
return [{ ...model, comboboxDraft: applyDraft(model.comboboxDraft, msg.edit), comboboxOpen: true }, Cmd.none];
|
||||
case "open_combobox":
|
||||
return [{ ...model, comboboxOpen: true }, Cmd.none];
|
||||
case "close_combobox":
|
||||
return [{ ...model, comboboxOpen: false }, Cmd.none];
|
||||
case "choose_native":
|
||||
return [{ ...model, comboboxDraft: draft("Native SDK"), comboboxOpen: false }, Cmd.none];
|
||||
case "choose_canvas":
|
||||
return [{ ...model, comboboxDraft: draft("Canvas") , comboboxOpen: false }, Cmd.none];
|
||||
case "open_dialog":
|
||||
return [{ ...model, dialogOpen: true }, Cmd.none];
|
||||
case "close_dialog":
|
||||
return [{ ...model, dialogOpen: false }, Cmd.none];
|
||||
case "open_drawer":
|
||||
return [{ ...model, drawerOpen: true }, Cmd.none];
|
||||
case "close_drawer":
|
||||
return [{ ...model, drawerOpen: false }, Cmd.none];
|
||||
case "toggle_only_unread":
|
||||
return [{ ...model, onlyUnreadChecked: !model.onlyUnreadChecked }, Cmd.none];
|
||||
case "toggle_has_attachments":
|
||||
return [{ ...model, hasAttachmentsChecked: !model.hasAttachmentsChecked }, Cmd.none];
|
||||
case "toggle_compact_rows":
|
||||
return [{ ...model, compactRows: !model.compactRows }, Cmd.none];
|
||||
case "toggle_dropdown":
|
||||
return [{ ...model, dropdownOpen: !model.dropdownOpen }, Cmd.none];
|
||||
case "close_dropdown":
|
||||
return [{ ...model, dropdownOpen: false }, Cmd.none];
|
||||
case "dropdown_duplicate":
|
||||
return [{ ...model, dropdownChoice: "duplicate", dropdownOpen: false }, Cmd.none];
|
||||
case "dropdown_rename":
|
||||
return [{ ...model, dropdownChoice: "rename", dropdownOpen: false }, Cmd.none];
|
||||
case "dropdown_download":
|
||||
return [{ ...model, dropdownChoice: "download", dropdownOpen: false }, Cmd.none];
|
||||
case "dropdown_delete":
|
||||
return [{ ...model, dropdownChoice: "delete", dropdownOpen: false }, Cmd.none];
|
||||
case "input_edited":
|
||||
return [{ ...model, inputDraft: applyDraft(model.inputDraft, msg.edit) }, Cmd.none];
|
||||
case "previous_page":
|
||||
return [{ ...model, page: model.page > 1 ? model.page - 1 : 1 }, Cmd.none];
|
||||
case "next_page":
|
||||
return [{ ...model, page: model.page < 3 ? model.page + 1 : 3 }, Cmd.none];
|
||||
case "page_one":
|
||||
return [{ ...model, page: 1 }, Cmd.none];
|
||||
case "page_two":
|
||||
return [{ ...model, page: 2 }, Cmd.none];
|
||||
case "page_three":
|
||||
return [{ ...model, page: 3 }, Cmd.none];
|
||||
case "density_default":
|
||||
return [{ ...model, density: "default" }, Cmd.none];
|
||||
case "density_comfortable":
|
||||
return [{ ...model, density: "comfortable" }, Cmd.none];
|
||||
case "toggle_select":
|
||||
return [{ ...model, selectOpen: !model.selectOpen }, Cmd.none];
|
||||
case "close_select":
|
||||
return [{ ...model, selectOpen: false }, Cmd.none];
|
||||
case "select_production":
|
||||
return [{ ...model, selectChoice: "production", selectOpen: false }, Cmd.none];
|
||||
case "select_staging":
|
||||
return [{ ...model, selectChoice: "staging", selectOpen: false }, Cmd.none];
|
||||
case "select_development":
|
||||
return [{ ...model, selectChoice: "development", selectOpen: false }, Cmd.none];
|
||||
case "open_sheet":
|
||||
return [{ ...model, sheetOpen: true }, Cmd.none];
|
||||
case "close_sheet":
|
||||
return [{ ...model, sheetOpen: false }, Cmd.none];
|
||||
case "slider_changed":
|
||||
if (!(msg.fraction >= 0 && msg.fraction <= 1)) return [model, Cmd.none];
|
||||
return [{ ...model, sliderValue: msg.fraction }, Cmd.none];
|
||||
case "toggle_notifications":
|
||||
return [{ ...model, notificationsEnabled: !model.notificationsEnabled }, Cmd.none];
|
||||
case "toggle_airplane_mode":
|
||||
return [{ ...model, airplaneMode: !model.airplaneMode }, Cmd.none];
|
||||
case "tab_account":
|
||||
return [{ ...model, tab: "account" }, Cmd.none];
|
||||
case "tab_password":
|
||||
return [{ ...model, tab: "password" }, Cmd.none];
|
||||
case "tab_team":
|
||||
return [{ ...model, tab: "team" }, Cmd.none];
|
||||
case "textarea_edited":
|
||||
return [{ ...model, textareaDraft: applyDraft(model.textareaDraft, msg.edit) }, Cmd.none];
|
||||
case "toggle_bold":
|
||||
return [{ ...model, bold: !model.bold }, Cmd.none];
|
||||
case "toggle_italic":
|
||||
return [{ ...model, italic: !model.italic }, Cmd.none];
|
||||
case "align_left":
|
||||
return [{ ...model, alignment: "left" }, Cmd.none];
|
||||
case "align_center":
|
||||
return [{ ...model, alignment: "center" }, Cmd.none];
|
||||
case "align_right":
|
||||
return [{ ...model, alignment: "right" }, Cmd.none];
|
||||
case "list_report":
|
||||
return [{ ...model, listSelection: "report" }, Cmd.none];
|
||||
case "list_checklist":
|
||||
return [{ ...model, listSelection: "checklist" }, Cmd.none];
|
||||
case "list_archive":
|
||||
return [{ ...model, listSelection: "archive" }, Cmd.none];
|
||||
case "toggle_src":
|
||||
return [{ ...model, srcExpanded: !model.srcExpanded }, Cmd.none];
|
||||
case "toggle_assets":
|
||||
return [{ ...model, assetsExpanded: !model.assetsExpanded }, Cmd.none];
|
||||
case "tree_src":
|
||||
return [{ ...model, treeSelection: "src" }, Cmd.none];
|
||||
case "tree_main":
|
||||
return [{ ...model, treeSelection: "main" }, Cmd.none];
|
||||
case "tree_view":
|
||||
return [{ ...model, treeSelection: "view" }, Cmd.none];
|
||||
case "tree_assets":
|
||||
return [{ ...model, treeSelection: "assets" }, Cmd.none];
|
||||
case "tree_logo":
|
||||
return [{ ...model, treeSelection: "logo" }, Cmd.none];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user