Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 222b8a79ff | |||
| 42f5cc287b | |||
| 03a9693666 | |||
| b25cefe318 | |||
| ef3ba18168 | |||
| 465a163e27 | |||
| a33d579177 | |||
| 393a0ed36e | |||
| e8f9e4ee50 | |||
| 659c893b29 | |||
| 8d0da34e62 | |||
| 1c1fba0c0f | |||
| 86fecf6cee | |||
| 23d0f5908a | |||
| 919d0e6cdf | |||
| 5d7fee8262 | |||
| 5c32accf12 | |||
| d575734635 | |||
| ee63266095 | |||
| e7c161970c | |||
| ddd975e4ea | |||
| 474cb5e364 | |||
| e6ac6ac4fc | |||
| 0ecdc7d2e9 | |||
| e924d7fcac | |||
| 41c4cdc47a | |||
| 31d5b202bc | |||
| 2bc942db46 | |||
| 199b89e06d | |||
| 716eb27c53 | |||
| baef0d96d3 | |||
| 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 | |||
| 8f1da1831b | |||
| 476173b6b5 | |||
| 19519dd5ea | |||
| 04b97cc2b7 | |||
| 8014e796a4 | |||
| a59015a246 | |||
| ad6fa36f3e | |||
| 7636ec3686 | |||
| bd3aab4b48 | |||
| 6a871356b4 | |||
| c1bad63c5f | |||
| 3636af4b45 | |||
| ef1f8d9cdd | |||
| 868d0116b0 | |||
| d866d922eb | |||
| a7509a7fa6 | |||
| ea98365a2d | |||
| 7b5b226fb2 | |||
| 5f48ec3f67 | |||
| 514ce820da | |||
| db34c23ea5 | |||
| daefe82106 | |||
| 4c3fb0bc30 | |||
| a43855001a | |||
| a0947a227d | |||
| d84536d04a | |||
| beb1d8712a | |||
| 2209f022ee | |||
| cce5359d5b | |||
| dbe15a7472 | |||
| 19f2933e08 | |||
| 3a188de6b7 | |||
| 72ed3644c6 | |||
| f8c14c59c7 | |||
| 501b59e490 | |||
| 7563a4e61a | |||
| dd9307656a | |||
| b57224d0d1 | |||
| ac06f6c25e | |||
| 64ab5f205c | |||
| 87fa3f92d0 | |||
| 2d23d6f449 | |||
| e48af75fcc | |||
| 2eb9424c63 | |||
| e67d3bd9c5 | |||
| 349618a138 | |||
| f4b9d26258 | |||
| 4be334fd40 | |||
| 6f526a9c1e | |||
| c09a12669e | |||
| 4a947ef2d5 | |||
| 4f22071c82 | |||
| e59091060f | |||
| b566850b46 | |||
| 57bf56bc58 | |||
| 4a19caa338 | |||
| 2c028094bb | |||
| 2027ff03b8 | |||
| 1dfb0db4f2 | |||
| 1e6b615674 |
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The GLib 2.72 receipt's error-set allowlist.
|
||||
|
||||
Compiling gtk_host.c on stock ubuntu 22.04 (GLib 2.72, GTK 4.6) cannot
|
||||
succeed: the toolkit's GTK floor is 4.10, so GTK-age failures are the
|
||||
expected steady state. What this receipt pins is that NOTHING ELSE
|
||||
fails - a glib/gio symbol needing 2.74+ without a version-checked
|
||||
fallback shows up here as a diagnostic outside the allowlist below.
|
||||
|
||||
The allowlist is by diagnostic SHAPE, not symbol prefix:
|
||||
- undeclared gtk_/GTK_ functions are the GTK-age roots;
|
||||
- undeclared plain (non-glib-namespaced) identifiers are their
|
||||
cascades (locals whose declaring line failed);
|
||||
- int-conversion lines are cascades of undeclared functions returning
|
||||
int, and incidentally name glib types (GListModel), so a prefix
|
||||
denylist would false-positive on them.
|
||||
Everything else fails the step: unknown type name 'G...', undeclared
|
||||
g_/G_ symbols, missing members, any located shape not seen before,
|
||||
and any error line WITHOUT a file:line:col location (driver failures
|
||||
like "error: Unknown Clang option" never classify as diagnostics, so
|
||||
they must reject rather than sail through an empty error set).
|
||||
|
||||
The receipt also demands positive evidence it ran: at least
|
||||
MIN_GTK_ROOTS allowlisted GTK-age root diagnostics. A compile that
|
||||
produced no classifiable error set (wrong file, broken include path,
|
||||
invocation failure) proves nothing and must fail loudly - the clean
|
||||
run produces ~21 roots, so the floor sits far below real variance
|
||||
while catching "nothing actually compiled".
|
||||
|
||||
Why the cascade allowances are sound despite looking broad: this
|
||||
receipt is one lane in a lattice, not the sole guard on gtk_host.c.
|
||||
Every full-GTK lane (linux-webkitgtk, the canvas smokes, macOS)
|
||||
compiles the same file cleanly, so a typo'd local or an independent
|
||||
conversion bug is a red build elsewhere before it ever reaches this
|
||||
filter - the only errors unique to this lane are the old-glib delta.
|
||||
And within that delta, regressions always announce themselves through
|
||||
a REJECTED root before their cascades matter: a missing glib function
|
||||
is "call to undeclared function 'g_...'" (only gtk_/GTK_ roots are
|
||||
allowed), a missing glib type/macro is an unknown-type-name or
|
||||
undeclared-G_-identifier line - all rejected. The cascades allowed
|
||||
below can only follow roots this filter already failed the step for,
|
||||
or GTK-age roots it exists to permit.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
MIN_GTK_ROOTS = 5
|
||||
|
||||
located = re.compile(r"^[^:\n]+:\d+:\d+: error: (.*)")
|
||||
rejected = []
|
||||
gtk_roots = 0
|
||||
for line in sys.stdin:
|
||||
if "error:" not in line:
|
||||
continue
|
||||
m = located.match(line)
|
||||
if not m:
|
||||
rejected.append(line.rstrip() + " [unlocated error shape - driver or invocation failure]")
|
||||
continue
|
||||
msg = m.group(1)
|
||||
if re.match(r"call to undeclared function '(gtk_|GTK_)", msg):
|
||||
gtk_roots += 1
|
||||
continue
|
||||
if re.match(r"use of undeclared identifier '(?!g_|G_|G[A-Z])", msg):
|
||||
continue
|
||||
if "incompatible integer to pointer conversion" in msg and "from 'int'" in msg:
|
||||
# Only the cascade signature: an undeclared function defaults to
|
||||
# returning int, so its assignment lines convert FROM 'int'.
|
||||
# Conversions from any other type are not that cascade - reject.
|
||||
continue
|
||||
rejected.append(line.rstrip())
|
||||
|
||||
if rejected:
|
||||
print("non-GTK-age diagnostics against GLib 2.72 - the pre-2.74 fallback story regressed:")
|
||||
print("\n".join(rejected))
|
||||
sys.exit(1)
|
||||
if gtk_roots < MIN_GTK_ROOTS:
|
||||
print(
|
||||
f"only {gtk_roots} GTK-age root diagnostics (need >= {MIN_GTK_ROOTS}) - "
|
||||
"the compile did not exercise the old-GTK error set, so this receipt proved nothing"
|
||||
)
|
||||
sys.exit(1)
|
||||
print(f"fallback receipt ok: every error is GTK-age by shape ({gtk_roots} roots)")
|
||||
@@ -15,8 +15,10 @@
|
||||
# 3. gpu_backend=software (the software present path is active)
|
||||
# 4. gpu_nonblank=true (real pixels were presented)
|
||||
# 5. widget-click "Add task" -> '4 open' (automation input mutates state)
|
||||
# 6. automate screenshot renders a non-empty PNG
|
||||
# 7. ZERO WebKit helper processes for the whole run (a native-only app
|
||||
# 6. a real X11 right-click opens a task row's declared context menu and
|
||||
# clicking its item dispatches the Msg ('1 done')
|
||||
# 7. automate screenshot renders a non-empty PNG
|
||||
# 8. ZERO WebKit helper processes for the whole run (a native-only app
|
||||
# has no web layer to boot WebKit with)
|
||||
#
|
||||
# Deliberately NOT `set -e` (same as windows-canvas-smoke.sh): grep exits 1
|
||||
@@ -56,12 +58,13 @@ app_log="${TMPDIR:-/tmp}/linux-canvas-smoke-app.log"
|
||||
ready_timeout_ms=90000
|
||||
|
||||
app_pid=""
|
||||
xvfb_pid=""
|
||||
cleanup() {
|
||||
[ -n "$app_pid" ] && kill "$app_pid" >/dev/null 2>&1
|
||||
# xvfb-run does not forward signals to an already-detached app; reap the
|
||||
# app and its Xvfb directly so local runs exit clean (CI would otherwise
|
||||
# rely on the runner's orphan sweep).
|
||||
# Reap the app and our Xvfb directly so local runs exit clean (CI would
|
||||
# otherwise rely on the runner's orphan sweep).
|
||||
pkill -f "$app_dir/zig-out/bin/ui-inbox" >/dev/null 2>&1
|
||||
[ -n "$xvfb_pid" ] && kill "$xvfb_pid" >/dev/null 2>&1
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -73,9 +76,26 @@ diagnostics() {
|
||||
head -20 "$app_log" 2>/dev/null | sed 's/^/ /'
|
||||
echo "-- app log tail ($app_log):"
|
||||
tail -40 "$app_log" 2>/dev/null | sed 's/^/ /'
|
||||
# The X window list names what is actually on the glass — the context
|
||||
# menu popover is an override-redirect X window that never appears in
|
||||
# the automation snapshot, so this is the only record of whether it
|
||||
# (or anything else) was mapped when a step failed.
|
||||
echo "-- X windows (xwininfo -root -children):"
|
||||
if [ -n "${DISPLAY:-}" ] && command -v xwininfo >/dev/null 2>&1; then
|
||||
xwininfo -root -children 2>/dev/null | sed 's/^/ /'
|
||||
else
|
||||
echo " (no DISPLAY or xwininfo not installed)"
|
||||
fi
|
||||
echo "---------------------"
|
||||
}
|
||||
|
||||
# The hex ids of every child of the root window, popovers included
|
||||
# (override-redirect windows never pass through a window manager — Xvfb
|
||||
# has none anyway — but they are always children of the root).
|
||||
x_window_ids() {
|
||||
xwininfo -root -children 2>/dev/null | grep -oE '0x[0-9a-f]+'
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $1"
|
||||
diagnostics
|
||||
@@ -108,9 +128,23 @@ assert_no_webkit() {
|
||||
echo "== native-only ELF audit ok"
|
||||
|
||||
# ---- launch ---------------------------------------------------------------
|
||||
# The script owns its Xvfb (instead of wrapping the app in xvfb-run) so
|
||||
# the xdotool step below shares the app's display. -displayfd picks a
|
||||
# free display number, the modern equivalent of xvfb-run -a's probing.
|
||||
display_file="$(mktemp)"
|
||||
Xvfb -displayfd 4 -screen 0 1280x800x24 4>"$display_file" &
|
||||
xvfb_pid=$!
|
||||
for _ in $(seq 1 100); do
|
||||
[ -s "$display_file" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ -s "$display_file" ] || fail "Xvfb never reported a display number"
|
||||
export DISPLAY=":$(cat "$display_file")"
|
||||
echo "== Xvfb on $DISPLAY"
|
||||
|
||||
cd "$app_dir" || fail "missing $app_dir"
|
||||
rm -rf .zig-cache/native-sdk-automation
|
||||
xvfb-run -a "$app_dir/zig-out/bin/ui-inbox" > "$app_log" 2>&1 &
|
||||
"$app_dir/zig-out/bin/ui-inbox" > "$app_log" 2>&1 &
|
||||
app_pid=$!
|
||||
|
||||
# ---- 2: automation snapshot becomes ready ---------------------------------
|
||||
@@ -139,12 +173,104 @@ add_id=$(grep -o 'widget @w1/inbox-canvas#[0-9]* role=button name="Add task"' "$
|
||||
|| fail "widget-click did not reach '4 open'"
|
||||
echo "== open after click: $(grep -oE '[0-9]+ open' "$snap" | head -1)"
|
||||
|
||||
# ---- 6: screenshot renders a non-empty PNG ---------------------------------
|
||||
# ---- 6: a real right-click opens and drives a task-row context menu --------
|
||||
# What this proves: an X-level SECONDARY-button press (GDK button 3)
|
||||
# travels the whole GTK path — click gesture -> button mapping -> runtime
|
||||
# secondary check -> declared-menu lookup -> native popover — and
|
||||
# clicking the popover's "Toggle done" item dispatches the row's Msg,
|
||||
# observable as the model change '1 done' in the snapshot (the popover
|
||||
# itself is an OS surface and never appears in the snapshot, so the
|
||||
# dispatched selection is the provable signal).
|
||||
# The menu is driven by POINTER, never keyboard: under Xvfb there is no
|
||||
# window manager, so the popover's X window never receives keyboard
|
||||
# focus and `xdotool key Down/Return` dies on the canvas beneath it —
|
||||
# but pointer events resolve by position (and the popover holds the
|
||||
# pointer grab), so a click on the popover surface reaches the item. The
|
||||
# popover is an override-redirect X window of its own: it is found by
|
||||
# diffing the root's children across the right-click, and because the
|
||||
# row declares exactly one item and the popover draws no arrow, the
|
||||
# center of that new window IS "Toggle done".
|
||||
# Regression coverage: the swapped GDK button mapping this smoke was
|
||||
# blind to made every right-click arrive as MIDDLE and never open the
|
||||
# menu — under that defect the popover window never appears and the
|
||||
# step fails at the popover lookup, before any selection.
|
||||
# Limit: '1 done' proves the toggle Msg dispatched; it cannot attribute
|
||||
# the dispatch to the popover VISUALLY (no snapshot record of the OS
|
||||
# menu), but the click lands on the popover's own X window, so no
|
||||
# canvas-level path can consume it — the canvas never sees the press.
|
||||
echo "== done before right-click: $(grep -oE '[0-9]+ done' "$snap" | head -1)"
|
||||
row_line=$(grep -o 'widget @w1/inbox-canvas#[0-9]*[^|]*context_menu=\["Toggle done"\][^|]*' "$snap" | head -1)
|
||||
[ -n "$row_line" ] || fail "no task row with the declared context menu in snapshot"
|
||||
bounds=$(echo "$row_line" | grep -o 'bounds=([^)]*)')
|
||||
bx=$(echo "$bounds" | sed -n 's/bounds=(\([0-9.-]*\),.*/\1/p')
|
||||
by=$(echo "$bounds" | sed -n 's/bounds=([0-9.-]*,\([0-9.-]*\) .*/\1/p')
|
||||
bw=$(echo "$bounds" | sed -n 's/.* \([0-9.]*\)x[0-9.]*).*/\1/p')
|
||||
bh=$(echo "$bounds" | sed -n 's/.* [0-9.]*x\([0-9.]*\)).*/\1/p')
|
||||
[ -n "$bx" ] && [ -n "$by" ] && [ -n "$bw" ] && [ -n "$bh" ] || fail "could not parse row bounds: $row_line"
|
||||
win=""
|
||||
for w in $(xdotool search --name "Inbox" 2>/dev/null); do win="$w"; done
|
||||
[ -n "$win" ] || fail "app X window not found"
|
||||
eval "$(xdotool getwindowgeometry --shell "$win")"
|
||||
# Xvfb has no compositor, so GTK draws no CSD shadow and the X window is
|
||||
# exactly the client area; correct by the measured height difference the
|
||||
# same way windows-canvas-smoke.sh does, in case a runner image ever
|
||||
# composites.
|
||||
client_h=$(grep -o 'window @w1 "[^"]*" bounds=([^)]*)' "$snap" | head -1 \
|
||||
| sed -n 's/.*x\([0-9]*\)[^x]*$/\1/p')
|
||||
[ -n "$client_h" ] || client_h=$HEIGHT
|
||||
y_off=$((HEIGHT - client_h))
|
||||
[ "$y_off" -ge 0 ] 2>/dev/null || y_off=0
|
||||
cx=$(awk "BEGIN{printf \"%d\", $X + $bx + $bw / 2}")
|
||||
cy=$(awk "BEGIN{printf \"%d\", $Y + $y_off + $by + $bh / 2}")
|
||||
xdotool windowactivate "$win" >/dev/null 2>&1 || xdotool windowfocus "$win" >/dev/null 2>&1
|
||||
command -v xwininfo >/dev/null 2>&1 || fail "xwininfo not installed (x11-utils) — required to locate the popover's X window"
|
||||
# xdotool reports decimal window ids, xwininfo hexadecimal; compare in hex.
|
||||
win_hex=$(printf '0x%x' "$win")
|
||||
pre_windows=" $(x_window_ids | tr '\n' ' ') "
|
||||
echo "== right-clicking task row $bounds at ($cx,$cy)"
|
||||
xdotool mousemove "$cx" "$cy" click 3
|
||||
# Wait for the popover's X window: a viewable, non-trivial root child
|
||||
# that did not exist before the right-click.
|
||||
popover=""
|
||||
popover_geom=""
|
||||
for _ in $(seq 1 50); do
|
||||
# Root children list in stacking order (bottom to top): scan top-down
|
||||
# so the just-mapped popover wins over any other new surface.
|
||||
for w in $(x_window_ids | tac); do
|
||||
case "$pre_windows" in *" $w "*) continue ;; esac
|
||||
[ "$w" = "$win_hex" ] && continue
|
||||
geom=$(xwininfo -id "$w" 2>/dev/null)
|
||||
echo "$geom" | grep -q 'Map State: IsViewable' || continue
|
||||
pw=$(echo "$geom" | sed -n 's/^ *Width: *\([0-9]*\).*/\1/p')
|
||||
ph=$(echo "$geom" | sed -n 's/^ *Height: *\([0-9]*\).*/\1/p')
|
||||
[ -n "$pw" ] && [ -n "$ph" ] && [ "$pw" -gt 10 ] && [ "$ph" -gt 10 ] || continue
|
||||
popover="$w"
|
||||
popover_geom="$geom"
|
||||
break
|
||||
done
|
||||
[ -n "$popover" ] && break
|
||||
sleep 0.2
|
||||
done
|
||||
[ -n "$popover" ] || fail "context-menu popover X window never appeared after the right-click"
|
||||
px=$(echo "$popover_geom" | sed -n 's/^ *Absolute upper-left X: *\(-*[0-9]*\).*/\1/p')
|
||||
py=$(echo "$popover_geom" | sed -n 's/^ *Absolute upper-left Y: *\(-*[0-9]*\).*/\1/p')
|
||||
pw=$(echo "$popover_geom" | sed -n 's/^ *Width: *\([0-9]*\).*/\1/p')
|
||||
ph=$(echo "$popover_geom" | sed -n 's/^ *Height: *\([0-9]*\).*/\1/p')
|
||||
[ -n "$px" ] && [ -n "$py" ] || fail "could not parse popover geometry for $popover"
|
||||
mx=$((px + pw / 2))
|
||||
my=$((py + ph / 2))
|
||||
echo "== popover $popover at ${pw}x${ph}+${px}+${py}; clicking its only item at ($mx,$my)"
|
||||
xdotool mousemove "$mx" "$my" click 1
|
||||
"$cli" automate assert --timeout-ms 30000 '1 done' \
|
||||
|| fail "right-click menu selection did not dispatch the toggle Msg ('1 done')"
|
||||
echo "== done after menu selection: $(grep -oE '[0-9]+ done' "$snap" | head -1)"
|
||||
|
||||
# ---- 7: screenshot renders a non-empty PNG ---------------------------------
|
||||
"$cli" automate screenshot inbox-canvas || fail "CLI screenshot failed"
|
||||
test -s .zig-cache/native-sdk-automation/screenshot-inbox-canvas.png \
|
||||
|| fail "screenshot PNG missing or empty"
|
||||
|
||||
# ---- 7: still zero WebKit processes at the end of the run -------------------
|
||||
# ---- 8: still zero WebKit processes at the end of the run -------------------
|
||||
assert_no_webkit "at end of run"
|
||||
echo "== zero WebKit processes at end of run"
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# Linux DEBUG-scaffold smoke under Xvfb — the `native dev` shape, pinned.
|
||||
#
|
||||
# The linux-canvas-smoke job builds at the graph's release default, and
|
||||
# Release builds always use the LLVM backend — so a Debug-only x86_64
|
||||
# codegen fault can pass every release-shaped CI lane and still crash the
|
||||
# very first thing a new user runs (`native init` + `native dev` builds
|
||||
# Debug). This smoke is the receipt for that gap: it scaffolds the ts-core
|
||||
# template with the CLI (the `native init` default), builds it exactly as
|
||||
# the dev loop does (-Doptimize=Debug), runs it headless, and requires the
|
||||
# startup scene to actually come up — app_start, scene load, the
|
||||
# gpu_surface shell view created through the GTK host's C seam, and real
|
||||
# presented pixels. The original failure this pins: zig 0.16.0's
|
||||
# self-hosted x86_64 backend (the Debug default without use_llvm) shifts
|
||||
# the stack-passed arguments of `native_sdk_gtk_create_view`, so the host
|
||||
# read a garbage `role` pointer and segfaulted before the first frame.
|
||||
#
|
||||
# Deliberately NOT `set -e` (same reasoning as linux-canvas-smoke.sh):
|
||||
# every assertion goes through fail(), which dumps the snapshot and the
|
||||
# app log instead of dying silently on a swallowed capture.
|
||||
set -u
|
||||
|
||||
# GTK_A11Y=none: under Xvfb there is no session bus providing org.a11y.Bus,
|
||||
# and GTK4's a11y init blocks ~25 s before continuing (see
|
||||
# linux-canvas-smoke.sh, which measured it). Accessibility is not what
|
||||
# this smoke tests.
|
||||
export GTK_A11Y="${GTK_A11Y:-none}"
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
cli="$repo_root/zig-out/bin/native"
|
||||
work_dir="${TMPDIR:-/tmp}/native-linux-dev-smoke"
|
||||
app_name="dev-smoke"
|
||||
app_dir="$work_dir/$app_name"
|
||||
snap="$app_dir/.zig-cache/native-sdk-automation/snapshot.txt"
|
||||
app_log="${TMPDIR:-/tmp}/linux-dev-smoke-app.log"
|
||||
|
||||
# Same widened cold-start readiness budget as linux-canvas-smoke.sh:
|
||||
# shared runners stall tens of seconds before the first runtime event.
|
||||
ready_timeout_ms=90000
|
||||
|
||||
app_pid=""
|
||||
cleanup() {
|
||||
[ -n "$app_pid" ] && kill "$app_pid" >/dev/null 2>&1
|
||||
pkill -f "$app_dir/zig-out/bin/$app_name" >/dev/null 2>&1
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
diagnostics() {
|
||||
echo "---- diagnostics ----"
|
||||
echo "-- snapshot ($snap):"
|
||||
if [ -f "$snap" ]; then tr '|' '\n' < "$snap" | sed 's/^/ /'; else echo " (missing)"; fi
|
||||
echo "-- app log head ($app_log):"
|
||||
head -20 "$app_log" 2>/dev/null | sed 's/^/ /'
|
||||
echo "-- app log tail ($app_log):"
|
||||
tail -40 "$app_log" 2>/dev/null | sed 's/^/ /'
|
||||
echo "---------------------"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $1"
|
||||
diagnostics
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---- build the CLI ---------------------------------------------------------
|
||||
(cd "$repo_root" && zig build) || fail "root zig build (CLI) failed"
|
||||
|
||||
# ---- scaffold the default template (ts-core, native frontend) --------------
|
||||
rm -rf "$work_dir"
|
||||
mkdir -p "$work_dir" || fail "cannot create $work_dir"
|
||||
(cd "$work_dir" && "$cli" init "$app_name" --framework "$repo_root") \
|
||||
|| fail "native init failed"
|
||||
|
||||
# ---- build the app the way the dev loop does (Debug) ------------------------
|
||||
# `native build` forwards -D flags to the generated graph verbatim;
|
||||
# -Doptimize=Debug pins the exact mode `native dev` uses, and
|
||||
# -Dautomation=true arms the snapshot publisher this smoke asserts on.
|
||||
(cd "$app_dir" && "$cli" build -Doptimize=Debug -Dautomation=true) \
|
||||
|| fail "Debug scaffold build failed"
|
||||
|
||||
# ---- launch headless --------------------------------------------------------
|
||||
cd "$app_dir" || fail "missing $app_dir"
|
||||
rm -rf .zig-cache/native-sdk-automation
|
||||
xvfb-run -a "$app_dir/zig-out/bin/$app_name" > "$app_log" 2>&1 &
|
||||
app_pid=$!
|
||||
|
||||
# ---- startup scene reached the host: ready, then presented pixels ----------
|
||||
# ready=true alone proves the crash site passed: the snapshot publishes
|
||||
# only after app_start dispatched and the scene's shell views (the
|
||||
# gpu_surface view with its role/accessibility strings) were created
|
||||
# through native_sdk_gtk_create_view.
|
||||
"$cli" automate assert --timeout-ms "$ready_timeout_ms" 'ready=true' \
|
||||
|| fail "snapshot never became ready (startup scene did not come up)"
|
||||
"$cli" automate assert --timeout-ms 30000 'gpu_nonblank=true' \
|
||||
|| fail "gpu_nonblank never became true"
|
||||
|
||||
# ---- the template's UI actually built over the scene ------------------------
|
||||
"$cli" automate assert --timeout-ms 30000 'role=button name="Reset"' 'total: 0' \
|
||||
|| fail "template widgets missing from the snapshot"
|
||||
|
||||
echo "PASS: linux dev smoke (Debug scaffold booted its startup scene)"
|
||||
exit 0
|
||||
@@ -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 ->
|
||||
@@ -77,6 +77,21 @@ poll() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Print the first snapshot match, retrying reads that land between the
|
||||
# runtime's truncate and rewrite on a presented frame.
|
||||
snapshot_match() {
|
||||
local match=""
|
||||
for _ in $(seq 1 20); do
|
||||
match=$(grep -o "$1" "$snap" | head -1)
|
||||
if [ -n "$match" ]; then
|
||||
printf '%s\n' "$match"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- build ----------------------------------------------------------------
|
||||
(cd "$repo_root" && zig build) || fail "root zig build (CLI) failed"
|
||||
(cd "$app_dir" && zig build -Dtarget=x86_64-windows-gnu -Dplatform=windows -Dweb-engine=system -Dautomation=true) \
|
||||
@@ -101,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"
|
||||
grep -q 'gpu_backend=software' "$snap" || 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)" \
|
||||
@@ -111,7 +126,7 @@ echo "== canvas: $(grep -o 'gpu_backend=[a-z]*' "$snap" | head -1)" \
|
||||
|
||||
# ---- 4: automation widget-click mutates the model --------------------------
|
||||
echo "== open before click: $(grep -oE '[0-9]+ open' "$snap" | head -1)"
|
||||
add_id=$(grep -o 'widget @w1/inbox-canvas#[0-9]* role=button name="Add task"' "$snap" \
|
||||
add_id=$(snapshot_match 'widget @w1/inbox-canvas#[0-9]* role=button name="Add task"' \
|
||||
| grep -o '#[0-9]*' | tr -d '#')
|
||||
[ -n "$add_id" ] || fail "Add task button not found in snapshot"
|
||||
"$cli" automate widget-click inbox-canvas "$add_id" || fail "CLI widget-click failed"
|
||||
@@ -137,15 +152,20 @@ for w in $(xdotool search --name "." 2>/dev/null); do
|
||||
done
|
||||
[ -n "$win" ] || fail "app X window not found"
|
||||
eval "$(xdotool getwindowgeometry --shell "$win")"
|
||||
client_h=$(grep -o 'window @w1 "[^"]*" bounds=([^)]*)' "$snap" | head -1 \
|
||||
| sed -n 's/.*x\([0-9]*\)[^x]*$/\1/p')
|
||||
[ -n "$client_h" ] || client_h=$HEIGHT
|
||||
# The runtime rewrites snapshot.txt on every presented frame. Retry the
|
||||
# extraction instead of treating a read that lands between truncate and
|
||||
# write as a standard-frame window: that fallback erases the 30px Wine
|
||||
# caption correction and turns the real-input receipt into a flaky miss.
|
||||
client_line=$(snapshot_match 'window @w1 "[^"]*" bounds=([^)]*)')
|
||||
[ -n "$client_line" ] || fail "could not read client bounds from snapshot"
|
||||
client_h=$(printf '%s\n' "$client_line" | sed -n 's/.*x\([0-9]*\)[^x]*$/\1/p')
|
||||
[ -n "$client_h" ] || fail "could not read client height from snapshot"
|
||||
y_off=$((client_h - HEIGHT))
|
||||
[ "$y_off" -ge 0 ] 2>/dev/null || y_off=0
|
||||
echo "== x window $win: pos=($X,$Y) size=${WIDTH}x${HEIGHT} client_h=$client_h y_off=$y_off"
|
||||
xdotool windowactivate "$win" >/dev/null 2>&1 || xdotool windowfocus "$win" >/dev/null 2>&1
|
||||
|
||||
draft_line=$(grep -o 'widget @w1/inbox-canvas#[0-9]* role=textbox[^|]*' "$snap" | head -1)
|
||||
draft_line=$(snapshot_match 'widget @w1/inbox-canvas#[0-9]* role=textbox[^|]*')
|
||||
[ -n "$draft_line" ] || fail "draft textbox not found in snapshot"
|
||||
bounds=$(echo "$draft_line" | grep -o 'bounds=([^)]*)')
|
||||
bx=$(echo "$bounds" | sed -n 's/bounds=(\([0-9.]*\),.*/\1/p')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -15,28 +15,68 @@ 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
|
||||
|
||||
core-compiler-fences:
|
||||
name: Core Compiler Fences
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
# 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
|
||||
# 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. 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
|
||||
|
||||
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
|
||||
# The zero-config TypeScript runner must load app.zon menus before
|
||||
# automation can select their registered command ids.
|
||||
- run: zig build test-menu-bar-smoke
|
||||
# Signed-package seal pin: an ad-hoc signed package must pass
|
||||
# codesign --verify --strict (macOS runners are the only tier with
|
||||
# codesign; the step skips loudly anywhere else).
|
||||
@@ -59,12 +99,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
|
||||
@@ -75,13 +115,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
|
||||
@@ -102,7 +149,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
|
||||
@@ -112,20 +159,35 @@ jobs:
|
||||
# include path, or a conformance error in the embedded layer — is a
|
||||
# compile failure here, not a silent WebViewNotFound at runtime.
|
||||
- run: zig build test-webview-system-link -Dplatform=windows
|
||||
# Effects.spawn is a pipe-backed background transport. Run its
|
||||
# Windows-only PowerShell probe natively so removing CREATE_NO_WINDOW
|
||||
# cannot leave the platform-neutral and Wine lanes green.
|
||||
- run: zig build test-windows-effects-no-console
|
||||
# The registered-font receipt, natively on Windows: runs the
|
||||
# font-registry suite — registration validation, the glyph-budget
|
||||
# gate, present/reference pixel parity, and the Chinese-receipt
|
||||
# test that registers the committed subsetted Noto Sans SC through
|
||||
# the app-fonts seam and proves the rendered string is real glyphs,
|
||||
# not tofu — on real Windows, not under emulation. The font
|
||||
# pipeline is platform-neutral Zig, so this lane pins that neutral
|
||||
# truth on a Windows host (the Linux run lives in the Zig Core
|
||||
# lane's `zig build test`).
|
||||
- run: zig build test-canvas-fonts
|
||||
|
||||
cef-platform-tooling:
|
||||
name: CEF Platform Tooling
|
||||
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
|
||||
|
||||
@@ -140,35 +202,76 @@ jobs:
|
||||
- run: npm --prefix packages/native-sdk run version:check
|
||||
- run: npm --prefix packages/native-sdk run scripts:check
|
||||
|
||||
native-examples:
|
||||
name: Native Examples
|
||||
native-example-shards:
|
||||
name: Native Examples (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: 1/4
|
||||
step: test-examples-native-shard-1
|
||||
- name: 2/4
|
||||
step: test-examples-native-shard-2
|
||||
- name: 3/4
|
||||
step: test-examples-native-shard-3
|
||||
- name: 4/4
|
||||
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
|
||||
- name: Install GTK dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev
|
||||
- run: zig build test-examples-native
|
||||
# Every example test uses the null backend, so this lane needs no
|
||||
# GTK/WebKitGTK packages. The root build owns the round-robin shard
|
||||
# membership, keeping CI and the complete local group in one registry.
|
||||
- run: zig build ${{ matrix.step }}
|
||||
|
||||
windows-web-layer-audit:
|
||||
name: Windows Web Layer Audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: vercel-labs/setup-zig@v1
|
||||
with:
|
||||
version: 0.16.0
|
||||
# Declare-to-use, proven on real Windows executables: the
|
||||
# canvas-only ui-inbox cross-compiles without the embedded WebView
|
||||
# layer (no WebView2Loader.dll reference, no loader installed) and
|
||||
# the webview example keeps it.
|
||||
- run: zig build test-windows-web-layer-audit
|
||||
|
||||
# Preserve the established `CI / Native Examples` required-check name
|
||||
# while making it an aggregate receipt for every shard and the audit.
|
||||
native-examples:
|
||||
name: Native Examples
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- native-example-shards
|
||||
- windows-web-layer-audit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Confirm every native example lane passed
|
||||
env:
|
||||
EXAMPLE_SHARDS_RESULT: ${{ needs.native-example-shards.result }}
|
||||
WINDOWS_AUDIT_RESULT: ${{ needs.windows-web-layer-audit.result }}
|
||||
run: |
|
||||
test "$EXAMPLE_SHARDS_RESULT" = success
|
||||
test "$WINDOWS_AUDIT_RESULT" = success
|
||||
|
||||
linux-canvas-smoke:
|
||||
name: Linux Canvas Smoke
|
||||
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
|
||||
@@ -177,18 +280,102 @@ jobs:
|
||||
# only succeed if nothing in a native-only app needs the WebKitGTK
|
||||
# headers or pkg-config entry. (The seam under webkit-PRESENT
|
||||
# conditions is the linux-webkitgtk job's ELF cross-audit.)
|
||||
- name: Install GTK and Xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev xvfb
|
||||
# x11-utils provides xwininfo, which the smoke script uses to find
|
||||
# the context-menu popover's override-redirect X window (and to
|
||||
# dump the X window list in failure diagnostics).
|
||||
- name: Install GTK, Xvfb, xdotool, and x11-utils
|
||||
run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev xvfb xdotool x11-utils
|
||||
# The stub is the expected, configured state of every native-only
|
||||
# Linux app, so compiling the GTK host with the stub define must be
|
||||
# diagnostic-FREE, not merely successful: zig renders every clang
|
||||
# diagnostic of a failing translation unit as `error:` (serialized
|
||||
# clang diagnostics carry no severity into its error bundle), so
|
||||
# even an informational #pragma message in this path masquerades as
|
||||
# the build-killing error the moment any unrelated real error
|
||||
# appears in the file. `zig cc` runs clang in passthrough mode,
|
||||
# where warnings actually reach stderr — `zig build` only surfaces
|
||||
# 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 compiler cache would hide the diagnostics
|
||||
# this step exists to catch.
|
||||
- name: WebKitGTK stub compile is diagnostic-free
|
||||
run: |
|
||||
export ZIG_GLOBAL_CACHE_DIR=$(mktemp -d) ZIG_LOCAL_CACHE_DIR=$(mktemp -d)
|
||||
status=0
|
||||
out=$(zig cc -c src/platform/linux/gtk_host.c -DNATIVE_SDK_ALLOW_WEBKITGTK_STUB $(pkg-config --cflags gtk4) -o /tmp/gtk_host_stub.o 2>&1) || status=$?
|
||||
if [ "$status" -ne 0 ] || [ -n "$out" ]; then
|
||||
echo "the WebKitGTK stub compile must succeed with zero diagnostics (exit $status):"
|
||||
echo "$out"
|
||||
exit 1
|
||||
fi
|
||||
# Drives the gpu_surface software path under Xvfb: snapshot ready,
|
||||
# gpu_backend=software, gpu_nonblank=true, automation widget-click,
|
||||
# a rendered screenshot, an ELF audit that the built binary carries
|
||||
# no WebKitGTK reference, and ZERO WebKit helper processes (canvas
|
||||
# apps never boot WebKit). A11y env, the widened cold-start
|
||||
# a real xdotool right-click driving a task row's native context
|
||||
# menu end to end, a rendered screenshot, an ELF audit that the
|
||||
# built binary carries no WebKitGTK reference, and ZERO WebKit
|
||||
# helper processes (canvas apps never boot WebKit). A11y env, the widened cold-start
|
||||
# readiness budget (shared runners stall ~27 s before the first
|
||||
# runtime event), and failure forensics (dump snapshot + app log)
|
||||
# all live in the script.
|
||||
# runtime event), and failure forensics (dump snapshot, app log,
|
||||
# and X window list) all live in the script.
|
||||
- name: Build and drive ui-inbox headless
|
||||
run: .github/scripts/linux-canvas-smoke.sh
|
||||
# Durable receipt for the GLib 2.72 fallback (the pre-2.74
|
||||
# G_APPLICATION_DEFAULT_FLAGS shim in gtk_host.c): no stock image
|
||||
# pairs an old glib with GTK >= 4.10 (only backport distros do),
|
||||
# and ubuntu 22.04 ships GTK 4.6 — so a clean compile is
|
||||
# impossible here by design. This pins the error SET instead:
|
||||
# every diagnostic must match a GTK-age shape (the allowlist
|
||||
# script holds the shapes, why cascades are allowed, and the
|
||||
# positive-evidence floor) — a 2.74+ glib symbol, type, or
|
||||
# member used without a version-checked fallback surfaces as a
|
||||
# rejected diagnostic and fails this step, and so does a compile
|
||||
# that produced no classifiable error set at all. If this compile ever succeeds outright, the premise
|
||||
# changed (newer GTK in the image) and the receipt must be
|
||||
# re-verified rather than trusted.
|
||||
- name: GTK host GLib 2.72 fallback holds (error-set receipt)
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/src" -v "$(dirname "$(which zig)"):/zig" ubuntu:22.04 bash -ec '
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -q >/dev/null && apt-get install -y -q libgtk-4-dev pkg-config >/dev/null
|
||||
export ZIG_GLOBAL_CACHE_DIR=$(mktemp -d) ZIG_LOCAL_CACHE_DIR=$(mktemp -d)
|
||||
status=0
|
||||
out=$(/zig/zig cc -c /src/src/platform/linux/gtk_host.c -DNATIVE_SDK_ALLOW_WEBKITGTK_STUB $(pkg-config --cflags gtk4) -ferror-limit=0 -o /tmp/gtk_host_2272.o 2>&1) || status=$?
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "unexpected clean compile on ubuntu 22.04 - this receipt assumes GTK-age errors; re-verify what it proves now"
|
||||
exit 1
|
||||
fi
|
||||
echo "$out" | python3 /src/.github/scripts/glib272_error_allowlist.py
|
||||
'
|
||||
|
||||
linux-dev-smoke:
|
||||
name: Linux Dev Smoke (Debug scaffold)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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 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.
|
||||
- name: Install GTK and Xvfb
|
||||
run: sudo apt-get update && sudo apt-get install -y libgtk-4-dev xvfb
|
||||
# The Debug half of the Linux runtime story: linux-canvas-smoke runs
|
||||
# at the graph's release default, and Release always uses the LLVM
|
||||
# backend — a Debug-only x86_64 codegen fault (self-hosted backend,
|
||||
# the mode every fresh `native init` + `native dev` builds) stays
|
||||
# invisible to it. This job scaffolds the default template with the
|
||||
# CLI, builds it -Doptimize=Debug with automation, runs it under
|
||||
# Xvfb, and requires the startup scene to come up (ready=true,
|
||||
# gpu_nonblank=true, template widgets in the snapshot).
|
||||
- name: Scaffold and drive the Debug template headless
|
||||
run: .github/scripts/linux-dev-smoke.sh
|
||||
|
||||
windows-canvas-smoke:
|
||||
name: Windows Canvas Smoke (Wine)
|
||||
@@ -196,14 +383,30 @@ 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
|
||||
run: sudo apt-get update && sudo apt-get install -y wine xvfb xdotool
|
||||
# The Windows twin of the linux-canvas-smoke stub receipt: the
|
||||
# WebView2 stub is the expected, configured state of every
|
||||
# native-only Windows app, so cross-compiling the host with the
|
||||
# stub define must be diagnostic-free (see that job's step comment
|
||||
# for why even an informational #pragma message is dangerous, and
|
||||
# why the compile must run against a cold cache).
|
||||
- name: WebView2 stub cross-compile is diagnostic-free
|
||||
run: |
|
||||
export ZIG_GLOBAL_CACHE_DIR=$(mktemp -d) ZIG_LOCAL_CACHE_DIR=$(mktemp -d)
|
||||
status=0
|
||||
out=$(zig c++ -target x86_64-windows-gnu -std=c++17 -DNATIVE_SDK_ALLOW_WEBVIEW2_STUB -c src/platform/windows/webview2_host.cpp -o /tmp/webview2_host_stub.o 2>&1) || status=$?
|
||||
if [ "$status" -ne 0 ] || [ -n "$out" ]; then
|
||||
echo "the WebView2 stub cross-compile must succeed with zero diagnostics (exit $status):"
|
||||
echo "$out"
|
||||
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).
|
||||
@@ -216,7 +419,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
|
||||
@@ -235,7 +438,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
|
||||
@@ -245,7 +448,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
|
||||
@@ -255,14 +458,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
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
.DS_Store
|
||||
.zig-cache/
|
||||
zig-out/
|
||||
# Project-local package store for examples with third-party Zig
|
||||
# dependencies (zig fetches into it beside the build).
|
||||
zig-pkg/
|
||||
# CLI-generated build graph for zero-config apps (the examples here) — the
|
||||
# same entry `native init` writes into a new app's .gitignore.
|
||||
.native/
|
||||
@@ -19,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
|
||||
@@ -38,6 +42,7 @@ docs/tsconfig.tsbuildinfo
|
||||
|
||||
# Dev-tool dependency trees (installed per package, never committed)
|
||||
packages/core/node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# The CLI-materialized editor copy of @native-sdk/core inside the TS example
|
||||
# apps (node_modules is editor surface, never source — the same entry the
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,10 +2,536 @@
|
||||
|
||||
All notable changes to the Native SDK (formerly zero-native) will be documented in this file.
|
||||
|
||||
## 0.5.1
|
||||
## 0.9.3
|
||||
|
||||
<!-- release:start -->
|
||||
|
||||
### New Features
|
||||
|
||||
- **Model-driven TypeScript theme state**: Zero-config TypeScript apps can now derive the built-in pack, color scheme, and accent from committed model state while preserving manifest fallback, live system accessibility settings, deterministic replay, and the existing `themePack` helper (#378).
|
||||
- **Platform-correct line deletion**: Command+Backspace on macOS now deletes to the start of a field or logical textarea line across every editable canvas control, with matching TypeScript text helpers, controlled-state behavior, undo, and replay (#377).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Precise macOS file-drop routing**: AppKit drops now retain labeled canvas and WebView targets with top-left, view-local coordinates, while unlabeled window regions fall back to content coordinates (#374).
|
||||
- **Manifest menus in generated runners**: Zero-config TypeScript and Zig-core apps now load `app.zon` commands, shortcuts, and menus consistently in live and replay runners, including ejected-runner fallbacks (#376).
|
||||
- **Large TypeScript message unions compile reliably**: Generated shims now derive comptime scan quotas from message shape and identifier size, allowing wide unions to compile across persistence, channels, environment routing, and the full external-core pipeline (#375).
|
||||
- **Correct combobox Enter precedence**: A bound `on-submit` now handles Enter before trigger activation, so query submission no longer opens the picker or dispatches the wrong command (#373).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @MohakBajaj
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.9.2
|
||||
|
||||
### New Features
|
||||
|
||||
- **Flash-free accessory startup**: Apps can opt into accessory activation from `app.zon` to launch without a Dock icon or foreground flash, with tray-affordance validation, runtime composition, packaging support, and an updated menu-bar example (#358).
|
||||
- **Logical canvas radio groups**: Nested radios now form accessible single-selection groups with roving focus and consistent keyboard, pointer, handler, and naming semantics (#361).
|
||||
- **Budget-aware photo decoding**: Dynamic encoded images are downsampled across desktop and mobile codecs to fit a configurable registered-pixel budget, with independent source bounds, deterministic replay, and platform-level regression coverage (#366).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Correct anchored surfaces**: Floating and modal surfaces now dismiss without requiring focus, relayout after scroll restoration, resolve against the correct root, and behave consistently across window contexts (#363).
|
||||
- **Reliable autofocus and caret reveal**: Keyboard focus, autofocus, and automation now transactionally reveal offscreen targets while preserving collapsed end-caret selections in text editors (#364).
|
||||
- **Explicit link decoration**: Linked text spans now honor their underline flag while Markdown-generated links retain conventional underlines (#368).
|
||||
- **Stable macOS window geometry**: Fresh windows now distinguish restored, explicit, and default placement, while AppKit and CEF frame events consistently report content geometry without titlebar drift (#369, #370).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Consistent canvas controls and surfaces**: Checkbox and radio labels can contain markup consistently, while actionable states, disabled colors, variant accents, selection geometry, compact layouts, and zero-width strokes now render uniformly across the schema, runtime, accessibility tree, and documentation (#367).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @sepehr-safari
|
||||
|
||||
## 0.9.1
|
||||
|
||||
### New Features
|
||||
|
||||
- **Multi-item macOS menu bars**: Apps can now manage independent, keyed status items with model-driven updates, events, automation, journaling, and regression coverage (#343).
|
||||
- **Complete TypeScript file effects**: Secure, permission-gated effects now support bounded streaming reads, atomic writes, stat, append, and deletion while preserving deterministic record and replay behavior (#339, #350).
|
||||
- **Actionable desktop notifications**: Notification replacement identifiers and actions dispatch through the ordinary command path on macOS, Windows, and Linux (#347).
|
||||
- **Secondary-window lifecycle control**: Window descriptors can declare quit or hide-on-close behavior, preserve hidden-window identity when reopened, and expose the same model-driven window contract to TypeScript apps (#349, #351).
|
||||
- **Mobile TypeScript cores and services**: TypeScript apps with services now compile into iOS and Android library archives, with mobile packaging and device-level runtime coverage (#346).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Safe Linux alert dialogs**: GTK alert dialogs now initialize with a valid empty format string, avoiding a crash from a null constructor argument (#354).
|
||||
- **Correct compiled-core tuple returns**: The SDK now pins the scriptc tuple-normalization fix and verifies bare-model and effect-tuple ABI returns with a compiled-core regression (#356).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Stronger TypeScript core guidance and diagnostics**: Subset rules now distinguish permanent guarantees from deliberately deferred capabilities and point authors to the appropriate service alternative (#345).
|
||||
- **End-to-end services showcase**: The Feed Reader example now demonstrates the full TypeScript service workflow with typed feed parsing, shared data, fixtures, and replay coverage (#352).
|
||||
- **Updated compiler integration**: scriptc advances through 0.0.31 with refreshed generated contracts, compatibility fixtures, and compiler-surface references (#344, #356).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @ElSebas41
|
||||
- @johnlindquist
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### New Features
|
||||
|
||||
- **Ordinary TypeScript services behind a typed boundary**: Apps can place filesystem, process, JSON, regex, class, and exact-vendored npm work under `src/services/`; Native SDK generates the checked client and codecs, compiles a pinned static service executable, and carries keyed requests, typed streaming, cooperative cancellation, deadlines, supervision, and deterministic replay across the isolated boundary (#317, #321).
|
||||
- **Optional in-process TypeScript services**: Services can use the same boundary through a linked, runtime-localized worker pool with per-key FIFO ordering, parallel independent keys, streaming, timeout and trap isolation, and replay that never starts the carrier; the explicit opt-in now follows the compiler's Windows, Linux, macOS, and cross-target matrix while the isolated child remains the automatic default (#334, #337).
|
||||
- **Engine-owned model persistence**: TypeScript cores can persist committed models through capability-gated, atomically replaced snapshots with generated codecs, debounced writes, backup recovery, explicit restore and migration routes, rollback safety, and journal/replay support (#316).
|
||||
- **SQLite record storage**: TypeScript and Zig apps can use a capability-gated record store for deterministic atomic CRUD effects backed by bundled SQLite across desktop and mobile hosts, with devhost parity and a complete Record Store example (#320).
|
||||
- **Checked relational SQLite**: Append-only migrations, build-validated named SQL, transactions, generated typed commands and live-query subscriptions, replay, and the Relational Notes example make relational SQLite a first-class offline data layer across desktop and mobile (#326).
|
||||
- **Model-driven menu-bar apps**: TypeScript apps can derive status-item labels, icons, tooltips, and rich menus from committed model state, while new macOS effects control hidden startup, fullscreen, Dock visibility, and launch-at-login behavior across both native hosts (#311, #314).
|
||||
- **Platform services for TypeScript cores**: Typed effects now open external URLs, reveal filesystem paths, and format local time through validated macOS, Linux, and Windows backends (#315).
|
||||
- **App-scoped credentials**: TypeScript and Zig cores can store, load, and delete credentials through capability- and permission-gated native providers, with redacted journals, deterministic replay placeholders, and hermetic devhost stores across desktop and mobile (#335).
|
||||
- **Cross-compiled TypeScript cores**: The external core compiler now builds Linux and Windows GNU targets from macOS, Linux, or Windows and macOS targets from macOS, with target-independent contracts and cross-platform end-to-end batteries for Windows and Linux musl (#340).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Working documentation root**: `/docs`, `/docs/`, and the matching Markdown route now resolve to the Introduction instead of ending at a 404 (#338).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Faster retained desktop frames**: Animation pumping and Windows wake scheduling now avoid stalled or redundant work, profiling uses monotonic frame-correlated telemetry, and physical macOS and Windows performance gates protect input latency and frame budgets (#313).
|
||||
- **Measured, compiler-truth service tooling**: A dedicated TypeScript Services reference documents the two-tier model and failure semantics; production-carrier benchmarks measure cold start, latency, and throughput; generated compiler-surface references and manifest diffs keep capability claims mechanically honest; and scriptc advances through 0.0.28 with refreshed contracts and calibration (#325, #327, #328, #329, #333, #336).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @carvalab
|
||||
- @Railly
|
||||
- @camilocbarrera
|
||||
|
||||
## 0.8.4
|
||||
|
||||
### New Features
|
||||
|
||||
- **Streaming fetch responses for TypeScript cores**: `Cmd.fetch` can now deliver line-framed HTTP responses through typed message arms with deterministic terminal errors, loud cancellation, duplicate-key rejection, and bounded line sizes; the rebuilt Chatbot example streams Vercel AI Gateway replies with live model selection and a Stop action (#300).
|
||||
- **Desktop audio capture**: TypeScript cores can start bounded, timestamped microphone or system-output PCM streams on macOS and Windows with explicit lifecycle, permission, drop-count, and replay handling; the new Voice Memo example records, saves, and plays WAV files (#303).
|
||||
- **Customizable macOS DMG packaging**: `native package` now creates polished drag-to-Applications disk images with generated or custom Retina backgrounds, configurable Finder geometry, positioned app and Applications entries, and staged files, directories, or links (#304).
|
||||
- **Live TypeScript theme packs**: zero-config TypeScript apps can export `themePack(model)` to switch the built-in theme pack from app state without losing live system scheme, accessibility, accent, or scale inputs (#308).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Smooth macOS dialog blur**: Host backdrop blur now uses an optimized three-pass Gaussian approximation and correct dirty-region invalidation, eliminating flat or stale dialog backgrounds while preserving the established scrim treatment (#299).
|
||||
- **Byte-accurate PTY event keys**: TypeScript PTY event routes now expose echoed session keys as `Uint8Array`, matching the byte-text host, generated facade, and external-core contract (#307).
|
||||
- **Reliable keyboard widget navigation**: Interactive canvas lists, trees, menus, and anchored controls now retain logical focus across clipped rows, scroll keyboard targets into view, and paint active and focus-visible states consistently (#308).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **TypeScript component gallery**: The GPU component showcase is now a TypeScript core and Native markup app with isolated interactive specimens, model-driven Default and Geist switching, clearer navigation, and dedicated smoke coverage (#308).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @marcusschiesser
|
||||
- @NyxTools-M
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Packaged TypeScript app assets**: Runtime asset lookup now finds bundled macOS resources before the process working directory, so Native markup boot images such as the Kanban agent avatars render after launch (#297).
|
||||
- **Unclipped drag landing motion**: Dropped cards now stay in the lifted drag layer through their landing animation while neighboring reflow remains clipped within its swimlane (#297).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Denser Kanban showcase**: The seeded board now includes twice as many Jira-style tickets and removes redundant issue glyphs from card metadata (#297).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
|
||||
## 0.8.2
|
||||
|
||||
### New Features
|
||||
|
||||
- **Native drag and drop for TypeScript apps**: Native markup's new `on-drag` channel delivers live, release, and cancellation geometry to compiled cores while the renderer lifts the source under the pointer, preserves one globally keyed insertion slot, animates neighboring items, and supports Escape cancellation; TypeScript cores can also map native multi-file drops into ordinary deterministic messages through `dropMsg` (#285).
|
||||
- **Desktop notifications from model cores**: TypeScript apps can return fire-and-forget `Cmd.showNotification` effects and Zig apps can call `fx.showNotification`, with bounded validation and suppression during fake execution and session replay (#283).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Explicit zero canvas padding**: Programmatic and compiled or interpreted Native markup views now preserve `padding="0"` instead of replacing it with the widget kind's default padding (#288).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **TypeScript-first app authoring guidance**: Repository instructions, bundled skills, examples, package documentation, and the docs site now consistently lead with TypeScript cores and Native markup for new apps while keeping Zig as the explicit alternative and toolkit-extension tier (#284).
|
||||
- **Agent ticket Kanban showcase**: The TypeScript Kanban example now presents numbered OpenAI- and Claude-assigned tickets, uses an icon-only add action, keeps columns scrollable, and extends its end-to-end coverage for the updated drag geometry (#295).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @johnlindquist
|
||||
- @Railly
|
||||
|
||||
## 0.8.1
|
||||
|
||||
### New Features
|
||||
|
||||
- **Safe presentational HTML in Markdown**: Markdown now lowers common GitHub-style inline and block HTML into native widgets, including links, details, aligned containers, and caller-resolved images, while scripts, styles, forms, embeds, event attributes, and unsupported or malformed markup remain inert literal text (#280).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Reliable resolved Markdown images**: image discovery now follows renderable block starts, canonicalizes entity-encoded URLs consistently between loading and lookup, preserves aspect ratios within declared bounds, honors centered and end alignment, and ignores images inside comments, unsupported markup, code, and preformatted blocks (#281).
|
||||
- **Payload-free HTTP write requests**: `Effects.fetch` now sends an explicit zero-length body for POST, PUT, and PATCH requests without a payload, preventing debug-build crashes and emitting the required `Content-Length: 0` header (#277).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **History-driven release notes**: release preparation now builds the complete changelog entry and contributor list from the commits since the previous release, replacing the per-change fragment workflow (#278).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @Railly
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### New Features
|
||||
|
||||
- **Compiler-truth checks for TypeScript cores**: `native check` now ends with the pinned external core compiler's analyzer over the entry with the shipped SDK declarations mapped, so check and build share one compiler verdict. Type errors the frontend's own line would miss fail with the compiler's diagnostics verbatim; an analyzer that cannot reach a verdict defers to the build instead of wedging check.
|
||||
- **TypeScript cores compile through the external core compiler**: the frontend checks `src/core.ts` and emits its contract sidecar, the exact-pinned compiler builds a native archive, and the app links a generated mirror over it — no JS runtime in the binary, nothing to configure.
|
||||
- **The TS-to-Zig transpiled lane is removed** (a deliberate pre-1.0 break): `core_compiler = "transpiler"` in app.zon (and `-Dcore-compiler=transpiler`) is refused with a teaching, and `native check` runs the checker and contract only — no emitted Zig lands under `.native/check/`.
|
||||
- **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.
|
||||
- **Effective sidecar projection**: `corewire --effective-sidecar` emits the contract after explicit slot demotions, and staged facade/profile/sidecar triples now describe one compiled layout.
|
||||
- **Editable highlighted code**: `ui.code` and `<code>` keep their read-only default, while `editable` plus `on-input` opts into a syntax-colored multiline editor in both retained and direct rendering, with selection, caret-row highlighting, IME, clipboard, undo/redo, indentation-aware Tab input (tabs or inferred 2–8-space widths, defaulting to two spaces), and no textarea chrome.
|
||||
- **Markdown source highlighting**: `markdown`/`md` joins the code lexer names with themed headings, lists, emphasis, links, inline and fenced code, and comments; the code-editor example selects it for Markdown files.
|
||||
- **Stable line-number gutter**: numbered code reserves at least three marker columns, so short files keep a useful gutter while larger line counts still expand it.
|
||||
- **Double-click messages in Native markup**: `on-double-press` exposes the canvas runtime's additive double-click channel to `.native` views, so the first click can select or preview and the second can perform or pin without a timer. Multi-click chains stay scoped to one control and physical pointer, and a third click returns to the ordinary press action instead of repeating the double action.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Composable code presentation**: `ui.code` and `<code>` now provide bare highlighted content without their own background, border, radius, shadow, or padding; wrap them in a panel or card when surface chrome is wanted. An enabled line-number gutter remains opaque while horizontally scrolling so source glyphs cannot clash with its pinned markers.
|
||||
- **Flat tree keyboard hierarchy**: `treeitem` rows can declare a one-based `tree-level`, letting Left/Right find logical parents and children in loop-rendered flat trees, while `on-change` can keep arrow-key selection distinct from pointer activation.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Live code docs preview**: The Code component page now loads its real WASM-backed engine scene instead of silently remaining on the static screenshot fallback.
|
||||
- **Reliable large-code editing**: editable code now repaints only visible selected glyphs and caches longest-line width measurements, keeping large selections and steady-state no-wrap rendering inside bounded display-list and host-measurement budgets.
|
||||
- **Complete wrapped long lines**: scrolling a single logical line beyond 128 wrapped rows now pages its visible glyphs instead of leaving the remainder blank.
|
||||
- **Stable code-editor reads**: switching tabs no longer cancels a pinned file's load, and reopened secondary windows keep monotonic file-effect keys so late completions cannot populate a newer document.
|
||||
- **Unsaved-edit protection**: opening another folder or closing a secondary editor window now refuses while that window still has dirty documents.
|
||||
- **Steady editor tabs**: active and inactive tabs now share the same background and label alignment, so filenames no longer shift when selection changes.
|
||||
- **Balance explorer rows**: file-tree hover and selection backgrounds now keep even visual gutters beside the sidebar edge and split handle while preserving compact label alignment.
|
||||
- **Complete repository roots**: the explorer now indexes a folder when it expands instead of spending its bounded tree budget in an eager depth-first walk, so large subtrees cannot hide root files or unexplored sibling folders; `.next` and `.pnpm-store` remain visible but are not recursively indexed.
|
||||
- **Familiar file opening**: Command+Down Arrow now opens the selected tree file as a persistent tab; Command+Enter remains available to the focused control.
|
||||
- **Visible active tabs**: inactive tabs retain their bottom divider, and opening, clicking, or keyboard-cycling to a tab now minimally scrolls it into view horizontally without shifting an already visible tab.
|
||||
- **Distinct new windows**: Command+N now opens each editor window slightly down and to the right of the active window so the new window is immediately apparent.
|
||||
- **Clear empty-window title**: editor windows now show “Code Explorer” in the title bar until a folder is opened.
|
||||
- **Stable editable-code repainting**: syntax-highlighted editors now keep unique retained command IDs while edited text and highlighted spans occupy different runtime storage, preventing a selected editor from crashing when the app deactivates.
|
||||
- **Safe large widget text**: views keep their ordinary 64 KiB text pools inline and allocate practical source-file capacity only when a large layout or edit needs it, while edit, presentation, and context-menu workspaces stay off constrained native stacks and large single-line pastes continue stripping line breaks.
|
||||
- **Folder-only macOS open dialogs**: `allow_directories = true` now matches Linux and Windows by selecting directories rather than allowing files alongside them in AppKit and CEF hosts.
|
||||
- **Code-editor presentation polish**: JavaScript and TypeScript object keys and typed bindings now use the same syntax color as variables, while CSS declaration names retain their property color; numbered editors also use their full trailing width so fitting lines do not produce false horizontal scrolling.
|
||||
- **Complete JSX and TSX syntax highlighting**: JSX-family code blocks now combine JavaScript or TypeScript token coloring with JSX tags and attributes instead of treating the whole file as plain HTML outside `{…}` expressions.
|
||||
- **YAML syntax highlighting**: code surfaces, Markdown fences, and the code-editor example now recognize `yaml` and `yml`, coloring mapping keys, scalars, document markers, anchors, tags, and comments.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### New Features
|
||||
|
||||
- **Code component**: `ui.code` and markup `<code>` render highlighted source with the Geist Code Block palette in both built-in themes, wrapping by default, opt-in logical line numbers, unwrapped horizontal scrolling, and vertical scrolling for height-constrained surfaces; Markdown fences share the same component.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Bounded transformed code rendering**: heavily scaled code surfaces now degrade within the shared command and text-byte budgets instead of rejecting the entire display-list refresh.
|
||||
- **Polished Markdown lists and code blocks**: bullet and ordered-list markers now align with the first content line, while fenced code preserves source indentation and applies theme-aware highlighting with richer HTML/JSX tags and attributes.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
|
||||
## 0.6.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Native textarea editing shortcuts**: Up/Down now moves or extends the caret across visual lines, Command+Left/Right uses the current line boundary even through unbroken soft wraps, Command+Up/Down reaches the document boundary, and Command+Z / Command+Shift+Z provides bounded per-editor undo and redo from either the keyboard or macOS Edit menu while keeping controlled `TextBuffer` models synchronized.
|
||||
- **Textarea indentation**: spaces typed at the start of an empty line now remain visible and advance the caret under word wrapping.
|
||||
- **Textarea pointer selection**: Shift-click now extends the selection from the existing caret instead of replacing it.
|
||||
- **Textarea line endings**: caret movement, deletion, and controlled selections now treat CRLF line endings as one indivisible boundary.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### New Features
|
||||
|
||||
- **Reliable desktop overlay windows**: window declarations and runtime creation now support transparent, always-on-top, click-through, and passive-show presentation applied before first visibility; canvas windows reveal after their first alpha-correct present without stealing focus, fall back to a late reveal if rendering wedges, and Windows composites multiple canvas layers while rejecting child surfaces its layered presenter cannot display.
|
||||
- **Honest backend constraints**: Linux main WebViews inherit transparent-window alpha, macOS Chromium rejects transparent windows because windowed CEF content cannot supply alpha, and transparent Windows windows require chromeless chrome with no application menu because the layered compositor cannot capture Win32 non-client pixels.
|
||||
- **Resizable transparent Windows windows**: the layered presenter keeps the nearly invisible system resize frame pointer-targetable without filling intentional alpha-zero regions in the client.
|
||||
- **Hybrid overlay lifecycle**: canvas-only overlay windows stay free of implicit main WebViews in mixed WebView scenes and across hot reloads, while passive Linux windows restore from minimization without taking focus.
|
||||
- **Reliable explicit focus on macOS**: focusing a system-WebView window now activates the app before asking AppKit to make the window key, so an inactive app can come forward as requested.
|
||||
- **Imperative canvas overlays**: `runtime.createWindow` and `window.zero.windows.create` keep transparent windows without an explicit source canvas-only across hot reloads, and JavaScript can select the chromeless titlebar Windows requires.
|
||||
- **Idle overlays stay idle**: transparent canvas windows retain their last presented image without entering a display-rate repaint loop, while software presenters still rebuild fully once when their shared pixel buffer changes surfaces.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Container backgrounds render**: explicit backgrounds on `stack`, `row`, and `column` now paint across the laid-out frame with their configured radius.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @jasonkneen
|
||||
- @sepehr-safari
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Layered macOS cursors**: GPU surfaces now yield their cursor regions to higher-layer embedded webviews, so links, selectable text, and canvas widgets use the correct cursor in mixed canvas/webview windows such as Workbench.
|
||||
- **Pointer-selected text edits**: editable fields now send pointer caret and selection changes through `on-input`, so model-owned text buffers delete or replace the highlighted span instead of editing at a stale caret.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### New Features
|
||||
|
||||
- **The external-source channel — `fx.openChannel`, TEA subscriptions done our way**: apps with long-lived external sources (sockets, file watchers, app-managed worker threads) get a first-class, journaled way to wake the UI loop and produce a Msg — no more timer-polling a shared queue. `fx.openChannel(.{ .key, .on_event, .max_pending? })` returns a THREAD-SAFE `ChannelHandle` whose `post(bytes)` stages into a per-channel non-lossy FIFO, wakes the host, and delivers one `.data` event Msg per accepted post on the next drain (bytes in drain scratch, bounded at `max_effect_channel_bytes`); `fx.closeChannel(key)` flushes the staged backlog and delivers exactly one `.closed` terminal with final drop totals. Channels share the keyed families' one key space — occupied from open until close delivers — and never fail from the caller's view: a duplicate occupied key or a full table answers with one `.rejected` event.
|
||||
- **Back-pressure is part of the contract, and the post's answer names it**: `post` returns a `ChannelHandle.PostResult` — `.accepted`, `.dropped_full` (staging FIFO full: transient, skip and keep producing), `.dropped_oversized` (bytes over the post bound: a programming error no retry fixes), or `.closed` (the occupancy is over: exit the loop) — so a producer never has to guess "retry later" from "stop forever". Both drop answers count into `dropped_pending`/`dropped_total` on the NEXT delivered event — never silent drops, and never a blocked posting thread given a conforming host wake: the platform's `wake_fn` is contractually a bounded, non-blocking, enqueue-only nudge (documented at `PlatformServices.wake_fn`; every first-party host conforms — macOS `dispatch_async`, GTK `g_idle_add`, Win32 `PostMessageW`), and the runtime holds no channel lock across the call, so even a violating embedder wake hangs only its own posting thread, never a drain, close, or teardown. A violator still inside the hook at teardown is abandoned after a bounded wait, and the platform is then deliberately kept alive — destruction skipped, leaked process-lived, with one loud log — so the stale call can never execute into freed host state. Wakes are exactly as many as the loop needs: a refused post never wakes the host (a wake is issued only when a post makes new work drainable), and accepted posts COALESCE behind one latched wake per drain (a burst costs the host queue one entry, cleared at the drain boundary before it snapshots — so a post racing the drain always lands a fresh wake), meaning neither a refusal storm nor a fast producer can grow the loop's queue. Handle lifetime is safe by construction: the handle resolves through a generation-stamped process-lifetime header, so posts after close, after slot reuse, or after runtime teardown answer `.closed` instead of touching freed memory.
|
||||
- **The journal fingerprint moves, a conscious break**: the `.channel` effect-record kind journals every delivered event as executor truth at the drain boundary, post bytes INLINE (channel posts are small-message-shaped — no blob store detour). Replay feeds the recorded events verbatim and never NEEDS the source — the channel open is an ordinary replayed dispatch that PARKS the occupancy (the key registers as live, duplicate opens reject symmetrically, admission rejections regenerate) and returns an inert handle whose every post answers `.closed`. Honesty about what re-runs: the opening update re-executes under replay, so a producer launched unconditionally really starts — socket connects and blocking setup before its first post included — and is stopped only AT that first post; `ChannelHandle.live()` is the producer-launch check (false for parked replay handles, refused opens, and closed occupancies — advisory, the post's own answer stays authoritative), so producers that consult it before launching keep replay fully offline, the `examples/channel-monitor` pattern. Impossible records (bytes over the post bound, byte-carrying terminals) refuse replay as damage. Journals from earlier builds are refused at the preamble with the standard re-record teaching.
|
||||
- **Bridge refusal timing, a conscious break**: TS-tier refusals produced by the bridge itself — duplicate-spawn keys, image validation, channel admission — used to deliver their rejection Msg at the command cycle's own boundary, before anything else could run. They now stage into the engine's seq-stamped pending stream and deliver at the next host drain, so every rejection — engine-refused or bridge-refused — arrives in ONE seq-ordered stream in command order, which is what `Cmd.batch`'s performed-in-order contract requires across layers (a batch mixing the two authorities used to deliver its rejections out of order). The observable difference: a frame may render between the command cycle and the rejection Msg, so an app or test that asserted the rejection landed inside the same cycle now sees the intermediate model rendered once and the rejection one drain later.
|
||||
- **TS tier first-class**: `Cmd.channelOpen(key, { event })` / `Cmd.channelClose(key)` (wire opcodes 0x15/0x16, additive within cmd_format_version 3) with a five-field event arm matched by name (`key`/`state`/`bytes`/`droppedPending`/`droppedTotal`; the three-member `ChannelState` union checked at build time). Posting is deliberately not a TS verb — transpiled cores are single-threaded: the TS tier opens, closes, and receives, and the native side feeds through `Effects.channelHandle(key)`.
|
||||
- **`examples/channel-monitor`**: an app-owned worker thread samples its own process and posts each reading; the UI updates only when events arrive — no `fx.startTimer`, no polling, and Stop winds the detached worker down through the handle's own `.closed` answer, while a transient `.dropped_full` only skips a sample — the drop counters reach the status line.
|
||||
- **Horizontal and two-axis canvas scrolling**: scroll views declare `axis="vertical|horizontal|both"` (builder `axis:`), horizontal offsets ride `value-x` with the same source-wins reconcile as `value`, the engine draws a bottom-edge scrollbar, keyboard scrolling gains Left/Right/Home/End on horizontal-capable regions, and macOS native scroll drivers carry both axes with OS momentum and rubber-band.
|
||||
- **Independent per-axis wheel routing**: each axis of a wheel/trackpad gesture travels to the nearest ancestor scrollable on that axis, so a horizontal timeline holding a vertical list splits a diagonal gesture — `delta_y` scrolls the list, `delta_x` reaches the timeline.
|
||||
- **BREAKING — `ScrollState` is two-axis now**: the one-axis `{offset, velocity, viewport_extent, content_extent}` record (TS: `offset`/`velocity`/`viewportExtent`/`contentExtent`) was replaced by per-axis fields `offset_x`/`offset_y`, `velocity_x`/`velocity_y`, `viewport_extent_x`/`viewport_extent_y`, `content_extent_x`/`content_extent_y` (TS: `offsetX`…`contentExtentY`); migrate a vertical region by reading the `_y` fields where it read the old ones — an `on-scroll` arm still declaring the old shape fails the build with a teaching that names the new fields.
|
||||
- **Hover-driven Msgs — `on-hover-enter` / `on-hover-leave`**: widgets can now bind pointer hover as first-class TEA vocabulary (Elm's `onMouseEnter`/`onMouseLeave`): enter dispatches once when the pointer enters a bound 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. Legal on any element in markup and in Zig views (`ElementOptions.on_hover_enter` / `on_hover_leave`), and the TS tier gets the pair for free (payloadless events need no SDK types).
|
||||
- Binding hover makes the element hover-hittable the way a bound press makes it pressable — but never pressable: clicks keep falling through, no accessibility action is announced, and no hover wash appears (a quiet content tile that binds hover stays visually quiet). Nested bound elements track containment independently; enters fire outermost-first, leaves innermost-first.
|
||||
- Every enter is answered by exactly one eventual leave: the leave Msg is captured when the enter dispatches, so it still arrives when the exit is the element unmounting. Exits resolve exactly like the hover wash already does — moving off, the pointer leaving the window, dismissals, and content scrolling or reflowing out from under a stationary pointer all re-hit-test the last pointer position — and overlays occlude hover the way they occlude clicks.
|
||||
- Opt-in and free when unbound: apps that bind no hover handlers keep an empty containment chain, no extra rebuilds, and no journal traffic. Where bound, hover Msgs derive deterministically from already-journaled pointer input, so recorded sessions replay them byte-identically with no journal format change.
|
||||
- Touch honesty: hover comes from mouse and trackpad pointers only — touch input never synthesizes it, so anything reachable only by hover must stay reachable another way. Deliberate break: reserving pointer-id bit 63 as the touch-source stamp changes the meaning of a journaled field, so the session journal's semantic epoch moves and recordings from earlier builds refuse with the standard re-record teaching.
|
||||
- `examples/notes`: hovering a note row now previews its title, age, and word count in the status bar (the browser status-line convention) without committing the selection.
|
||||
- **Named keys grow `delete`, `home`, `end`, `pageup`, `pagedown`, `insert`, and `f1`–`f12`**: every desktop platform now reports them on GPU-surface key events (they previously surfaced on some platforms as private-use strings or not at all), and shortcuts and menu accelerators can bind them. Terminal-style consumers can encode the full navigation and function-key set; none of these require a modifier, matching platform convention (F5 alone is a valid accelerator).
|
||||
- **Native context menus on Windows and Linux**: a right-click on a widget with a declared menu (or the zero-code editable-text and selected-text defaults) now presents the OS menu at the pointer on Windows (`TrackPopupMenu`) and Linux (`GtkPopoverMenu`), with the selection or dismissal riding the same journaled `context_menu_action` event macOS already emits — one authored menu, one replayable outcome, three desktop platforms.
|
||||
- The `.context_menus` platform capability now reports true on both system-engine hosts, so feature-gated code takes the native path everywhere the system web engine runs.
|
||||
- The engine fallback surface (hosts with no native presenter) now anchors the menu at the click point instead of the target widget's edge, matching where the pointer actually is on wide targets.
|
||||
- Selections now resolve from a present-time snapshot of the shown items, so a menu left open across a rebuild (a timer reordering conditional items) dispatches the item the user saw, never the rebuilt tree's occupant of that slot.
|
||||
- Deliberate automation-protocol break: recorded `context_menu_action` tokens are per-request generations instead of widget ids, so the protocol semantic epoch moves. Recordings from earlier builds are refused loudly at the preamble (their context-menu selections would otherwise be silently swallowed by the token gate); re-record with this build.
|
||||
- **Windows pty transport — ConPTY, first-class**: `fx.ptySpawn` and the whole pty family now run on Windows through `CreatePseudoConsole` over an overlapped pipe pair, honoring the exact vocabulary contract the macOS/Linux backends implement — same spawn admission and environment policy (the bound host environment plus `TERM`; env names match case-insensitively, the Windows rule), same all-or-nothing `ptyWrite`, `ptyResize` via `ResizePseudoConsole`, `ptyKill` via `TerminateProcess` plus pseudoconsole teardown (which reaches every descendant still attached to the console), same coalesced output batches and lossless back-pressure, and the same exactly-one exit. The terminal example runs unchanged (its deterministic shell pick adds cmd.exe), and recorded sessions replay offline exactly as on POSIX.
|
||||
- **Encoding honesty**: the pseudoconsole's pipe contract is UTF-8 with VT sequences in both directions, and the backend creates it with flags 0 — no `PSEUDOCONSOLE_INHERIT_CURSOR`, so conhost never opens with a cursor-position handshake the app would have to answer. There are no console-mode calls to make host-side: the VT modes live inside the pseudoconsole's conhost.
|
||||
- **Differences stated plainly** (docs' platform matrix moved from "staged" to supported): Windows has exit codes only, so `signaled` never occurs there — a crash surfaces as `exited` with the NTSTATUS bit-cast to `i32` — and ConPTY output is conhost's VT rendering of the child's screen, not the child's raw byte stream.
|
||||
- **TS tier: the pty command family**: `Cmd.ptySpawn(argv, { cols?, rows?, term?, event })`, `Cmd.ptyWrite(key, bytes)`, `Cmd.ptyResize(key, cols, rows)`, and `Cmd.ptyKill(key)` (wire opcodes 0x19-0x1C) expose the pty vocabulary to transpiled cores, with an event arm matched by field name (`key`/`state`/`bytes`/`code`/`reason`/`signal`/`droppedWrites`), where `key` is the app's own session key so two sessions routing one arm stay distinguishable. The native side owns the transport; the TS tier spawns, writes, resizes, kills, and receives.
|
||||
- **`<terminal>` — the terminal as a markup built-in**: `ui.terminal(.{ .pty = key, .scrollback, .on_terminal })` (markup `<terminal pty={key} scrollback={offset} on-terminal="...">`) promotes the terminal from the example tier to a first-class element. It binds a model-owned pty effect key — the same id `fx.ptySpawn` named, the media-surface `surface` binding shape — and renders the framework-owned emulator session behind it: the grid painted as real text with geometric box drawing, a theme-derived ANSI palette, selection, cursor, and scrollback, all moved into the canvas (`canvas.TerminalGrid`, the `.terminal` widget kind) from the example. Focused, it routes keys, IME text, and wheel scrollback to the session; the live viewport text rides the widget's accessibility label so screen readers read the real screen and session fingerprints cover cell state.
|
||||
- **The terminal state contract**: `on-terminal` delivers a `canvas.TerminalState` (`scrollback`, `history`, `cols`, `rows`) after every runtime-applied view-state change, and `scrollback` echoes it back under the scroll `value` source-wins reconcile rule. Only app-visible view state crosses the boundary — the emulator's cells, modes, and selection pins stay framework-owned and are never model state. Expressible in both authoring tiers, matched structurally for transpiled cores.
|
||||
- **Teachings**: a `<terminal>` without `pty={binding}` is refused as dead markup (the media-surface-without-surface policy); a literal pty key, `pty`/`scrollback`/`on-terminal` on any other element, and children all teach exactly where they belong, in the validator and both markup engines alike.
|
||||
- **Live `<terminal>` sessions, runtime-owned**: binding a pty key with `<terminal pty={key}>` now renders a REAL session — the runtime feeds the key's journaled pty output into a framework-owned emulator, routes the focused element's keys and IME text back out through `ptyWrite`, answers device queries, scrolls history on the wheel, and drives `ptyResize` from the element's laid-out extent through the shared cell-metrics seam. An app's terminal is `fx.ptySpawn` plus the element: no emulator wiring, no key encoding, no grid plumbing. Because the emulator is fed from the journaled byte stream and every outbound byte crosses the journaled write path, a recorded session replays to the same screen with no shell present.
|
||||
- **Opt-in emulator, consumer-safe**: `AppOptions.terminal_sessions = true` (with a lazy `ghostty` pin in the app's own `build.zig.zon`) wires libghostty-vt behind the element; every other build — scaffolded apps, the docs preview, transpiled cores — gets a stub that renders the empty terminal surface and never traverses that dependency graph. `native_sdk.runtime.terminal_sessions_enabled` reports which half a build carries.
|
||||
- **`examples/workbench`**: a live terminal beside a browser in one resizable split — the terminal is the element (no emulator code in the app), the browser is a webview pane snapped to a markup anchor with app-owned navigation history behind back/forward, reload, and an address bar.
|
||||
- **Terminal — the pty effect vocabulary and a recordable terminal embed**: `fx.ptySpawn(.{ .key, .argv, .cols, .rows, .term?, .on_event })` opens a pseudo-terminal, forks the command onto it as its controlling terminal, and streams output back as coalesced `on_event` Msgs; `fx.ptyWrite(key, bytes)` sends stdin all-or-nothing and returns whether the payload was accepted (a caller that must not lose bytes retains a refusal and retries; verdicts are journaled so replay takes the identical path), `fx.ptyResize(key, cols, rows)` pushes a new grid (SIGWINCH), and `fx.ptyKill(key)` terminates the job. A pty is a spawn with a different transport — it rides the same `command` permission, the same environment policy, the same argv budgets, and the same one key space as spawns, fetches, and channels. macOS and Linux ship the real transport (openpty + a controlling terminal); Windows ships ConPTY (its own fragment); the null platform gets a scriptable fake pty so the whole vocabulary tests headless.
|
||||
- **Output is coalesced per frame, never per read, and back-pressure is lossless**: bytes arriving between drains deliver as one batch bounded at 64 KiB, so `cat largefile` journals per-frame batches instead of a record per `read()`. The transport's staging ring never drops a byte — a full ring parks the reader and the kernel slows the child, a terminal's native flow control — and the exit event reports `dropped_writes` for any `ptyWrite` refused over the session's life.
|
||||
- **One exit per spawn, honest classes**: exactly one `.exit` event ends every accepted (and every refused) spawn — `exited` with the child's code, `signaled` with the signal, `cancelled` after `ptyKill`, `rejected` for requests refused before a child existed (bad argv, zero grid, duplicate key, table full, unsupported platform), `spawn_failed` when the pty or exec could not start.
|
||||
- **Recorded sessions replay byte-identical, offline — no shell present**: output bytes are the effect result, written at effect-result time into the content-addressed blob store beside the journal (`blobs/<sha256[..16]>`, identical batches deduplicated), with the journal record carrying the hash and length. Replay never spawns a process: the `ptySpawn` parks the pty (writes/resizes/kills go inert), the journaled batches and exit feed verbatim from the blob store, and the fingerprint checkpoints verify the replayed emulator grid frame by frame. Adding the pty record kind moved the journal format fingerprint — older recordings refuse at the preamble with the standard re-record teaching.
|
||||
- **`examples/terminal`**: a keyboard-first terminal at the showcase bar — libghostty-vt (Ghostty's extracted VT core, pinned as the `ghostty-vt` Zig module) owns cell state, damage, scrollback, wrapping, reflow, and selection; the canvas paints the viewport as real text with theme-mapped ANSI-16, exact 256-color and truecolor, and wide CJK cells. Typing rides the IME-correct committed-text channel and the emulator's key encoder; cmd/ctrl+shift+space arms line/block cell selection, cmd/ctrl+arrows page the scrollback, and cmd/ctrl+C copies.
|
||||
- **`UiApp.Options.on_text`**: the target-less committed-text seam — `on_key`'s typing twin — for apps that consume text without a focused text-entry widget (a terminal grid). Delivered for unclaimed `text_input` after the same widget-precedence routing `on_key` yields to, carrying the committed UTF-8 (IME results included) so consumers stay layout- and input-method-correct. Chrome may also declare a `variable_prefix` prefix whose command count is model-derived, for chrome whose shape changes per frame (a terminal grid, a data plot).
|
||||
- **Video playback**: a new `<video>` element (registry code 68, attributes `controls`/`autoplay`/`loop`/`muted` at codes 82-85 with `src` riding the existing attribute) plays platform-decoded video through the media-surface texture channel — AVFoundation on macOS decodes straight into the compositor while the app core sees only commands and journaled events; Windows (Media Foundation) and Linux (GStreamer) stage the capability honestly: `video_playback` reports false and the load verbs answer with a teaching plus one explicit failed event until their decoders land.
|
||||
- **The video command/event vocabulary**: `fx.loadVideo` mirrors the audio channel end to end — local-then-URL source cascade with the http(s) scheme check, transport verbs (`playVideo`/`pauseVideo`/`stopVideo`/`seekVideo`/`setVideoVolume`/`setVideoMuted`/`setVideoLoop`), key-stamped events (`loaded` with stream dimensions and duration, position ticks with the honest `buffering` flag, one `completed` at a non-looping natural end, explicit `failed`/`rejected`), and replace semantics that release the surface claim; TypeScript cores get `Cmd.videoLoad`/`videoCtl` at wire opcodes 0x17/0x18 with the by-name seven-field event-arm convention.
|
||||
- **The session journal fingerprint moves** (a deliberate break — recordings from earlier builds are refused at the preamble; re-record with this build): the new `.video` effect-result kind (code 13) and platform-event tag journal every delivered event verbatim, so a recorded playback replays byte-identical on a host with no decoder and no texture producer attached, and texture contents stay out of session fingerprints exactly like every media-surface texture.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Build fingerprints replace version counters for the session journal and automation protocol**: the journal's `format_version` and the CLI/app `protocol` version are gone in favor of comptime layout fingerprints — a Wyhash over a canonical description reflected from the actual record, event, and command types — so any layout change moves the identity automatically, with no counter to remember to bump and no next integer for parallel branches to contend over. Since no journal or dropbox skew is ever migrated, identity beats ordering: "same or different" was the entire question the integers answered.
|
||||
- Deliberate break: journals and automation sessions recorded by any earlier build are refused with the re-record teaching (the journal preamble now carries the u64 format fingerprint; the snapshot header stamps `protocol=0x...`), and skew refusals name fingerprints instead of version numbers.
|
||||
- A small `semantic_epoch` remains for the rare meaning-only change with identical bytes (the context-menu token generations were one); layout changes need no action.
|
||||
- `zig build print-pins` and `native version` print the fingerprints, so a build's wire identities can be quoted exactly.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **No more stale fringes when content reflows**: incremental canvas damage now covers the anti-aliasing bleed — the up-to-one-device-pixel ring rasterizers ink past a command's bounds — so a list-detail selection change that reflows conditional content (badge pills removed, shrunk, moved, or replaced under new keys) no longer leaves leftover edge pixels where the old content extended beyond the new. Every finalized incremental dirty rect (the refined union, each refined cluster on the retained-patch wire, and the summary fallback) inflates by one device pixel before surface clipping; full repaints are unchanged.
|
||||
- **Terminal context menu**: right-clicking a `<terminal>` now presents the standard Copy and Paste actions, copying the emulator selection and sending pasted clipboard text to the bound PTY.
|
||||
- **Natural terminal editing on macOS**: focused terminals now translate Option+Left/Right to word movement, Command+Left/Right to line boundaries, and Command+Delete to clearing back to the line start, instead of leaking unsupported modifier sequences into the shell prompt; Command+V now sends clipboard text through the terminal's bracketed-paste-aware input path.
|
||||
- **Selectable terminal text**: `<terminal>` now supports pointer-drag cell selection, double-click word selection, triple-click line selection, and Cmd/Ctrl+C clipboard copy without forwarding the copy chord to the child.
|
||||
- **Terminal Tab input**: focused live `<terminal>` components now send Tab and Shift+Tab to the PTY for completion, indentation, and TUI navigation, while focus-entry gestures and ended or unbound terminals retain ordinary traversal.
|
||||
- **Video letterboxes instead of stretching**: the video surface now aspect-fits (contain) the decoded frame — centered at the stream's reported proportions, letterboxed or pillarboxed on black, never distorted. Contain is the video surface's one fit mode, stamped on the `<video>` element and on any app-claimed surface while its playback is live; unknown dimensions before the LOADED report keep the full-frame placeholder, and a source replacement re-fits from the new report. Camera and app-producer media surfaces are untouched.
|
||||
- **Clear terminal focus**: terminal cursors now fill while their live session owns keyboard focus and switch to a hollow outline when focus leaves or the session ends.
|
||||
- **Clean workbench terminal chrome**: the full-bleed terminal pane keeps keyboard focus without showing its clipped outer focus ring as a stray horizontal rule beneath the titlebar.
|
||||
- **Workbench pane focus stays truthful**: clicking the embedded page now blurs the address bar and hollows the terminal caret; clicking either canvas pane restores its expected keyboard focus.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @startewho
|
||||
|
||||
## 0.5.4
|
||||
|
||||
### New Features
|
||||
|
||||
- **`Cmd.imageLoad` — dynamic images, the first full media pipeline**: apps load images at runtime from disk or the network by a model-owned ImageId, the effect executor resolves the audio cascade's source order (local path first, then a verified content-addressed cache entry under `<caches>/images/`, then the network with an atomic cache install behind it), decodes through the platform codec into the existing registered-image storage, and exactly ONE result Msg comes back — `loaded` with the decoded width/height, or one honest failure class from the same vocabulary the direct registration API raises (`decode_failed`, `too_large`, `registry_full`, `unsupported`, `alloc_failed` — the host refused the memory the registration needed, resource exhaustion rather than corrupt bytes — the fetch taxonomy, `http_status` with the status carried through).
|
||||
- **TS tier first-class**: `Cmd.imageLoad(id, { path?, url?, cachePath?, expectedBytes? }, { event })` with a five-field result arm matched by name (`id`/`state`/`width`/`height`/`status` — `id` echoes the requested ImageId so concurrent loads sharing one arm stay distinguishable; the fifteen-member `ImageState` union checked at build time), id expressions welcome (ids are model data), `Cmd.imageCancel(id)` ending a live load loudly (the event arm's "cancelled", freeing the id for a same-id retry; an id with no live load no-ops), `Cmd.imageUnregister(id)` releasing a loaded image's registry slot (the gallery eviction move past the 16-slot registry — synchronous registry surgery like registration itself, no result Msg, misses no-op; a load in flight still registers at its terminal, so cancel first to keep the slot free), opcodes 0x12/0x13/0x14 additive within cmd_format_version 3, and `TsUiApp`'s `image_cache_dir` deriving the content-addressed cache path from the URL so update never builds filesystem paths.
|
||||
- **Markup `<image>` — the runtime-image leaf (element code 67)**: `image="{binding}"` binds the model-owned u64 ImageId in avatar's grammar (binding-only, required on the leaf, negative model values fail the build with a teaching, never a trap), wired through the validator, both engines, `native check`'s model contract, LSP hover docs, and the docs vocabulary; the `image` attribute's scope broadened from avatar-only to avatar+image.
|
||||
- **Recorded sessions replay byte-identical, offline**: an image load's ENCODED source bytes are the effect result, journaled at effect-result time into a content-addressed blob store beside the journal (`blobs/<sha256[..16]>` in the session directory — identical bytes twice store one blob), with the journal record carrying hash + length and the dedup probe verifying an existing blob's bytes before trusting its name (a damaged blob repairs in place from the bytes in hand — recording self-heals the store instead of sealing a journal replay must refuse); replay reads the blob, verifies it against its address, re-runs decode + registration, and delivers the recorded result with no file, network, or cache touched, refusing loudly when the blob store is missing or damaged.
|
||||
- **Journal format, stated plainly**: the image records bump the session-journal format to v7 (the `.image` effect-record kind plus the blob-address fields appended to every effect record); v6 and older journals are refused at the preamble with the standard re-record teaching — a v6 reader would have misparsed the longer records as corruption.
|
||||
- **Zig tier**: `fx.loadImage(.{ .id, .path, .url, .cache_path, .expected_bytes, .on_result })` with `Effects.imageMsg(...)` routing, a fake-executor seam (`pendingImageLoad*`, `feedImageBytes` running the REAL decode+register path, `feedImageResult`), and `imageCachePath` deriving the cache convention; the encoded source is bounded at 1.25 MiB from every source alike and over-bound sources fail whole with `too_large` — a cut image never decodes, so there is no truncated delivery.
|
||||
- **The menu-bar app lifecycle**: windows can declare `close_policy = "hide"` in app.zon (the default `"quit"` keeps today's behavior for every existing app) — the red close button hides the window instead of quitting, the app keeps running behind its status item, and the macOS Dock reopen re-shows it.
|
||||
- New window verbs on the effects channel: `fx.showWindow(label)` un-hides and activates a window (the tray "Open" consequence; also restores a minimized window), and `fx.quitApp()` quits gracefully through the same shutdown path a last-window close takes — both mirrored in the TypeScript tier as `Cmd.showWindow(label)` and `Cmd.quitApp()`.
|
||||
- Hidden state is honest, journaled window state: `WindowState.hidden` rides the frame channel, records into session journals, and replays.
|
||||
- Implemented on macOS (windowShouldClose + orderOut, Dock reopen) and Windows (WM_CLOSE hides via SW_HIDE; the tray re-shows); Linux GTK has no status item to bring a hidden window back, so `"hide"` is refused loudly at build/create time with a teaching instead of stranding a window.
|
||||
|
||||
### Improvements
|
||||
|
||||
- **The tofu guard teaches font registration**: the font-coverage teachings — the `native markup check` error, the Debug view-build diagnostic, and the CLI usage text — now name registering a covering face (`UiApp.Options.fonts`) behind a model binding as the first remedy for text beyond bundled coverage, alongside vector icons and plain words.
|
||||
- **A fonts page in the docs**: `/fonts` documents registering faces for scripts beyond bundled coverage — the `Options.fonts` scaffold shape, every registration-time validation error by name, ownership and lifecycle, how text resolves faces through the typography tokens, and per-platform truth including the unverified mobile seam.
|
||||
- **The Chinese receipt runs natively on Windows in CI**: a new `zig build test-canvas-fonts` step runs the font-registry suite on the Windows runner, including the receipt test that registers a committed subsetted Noto Sans SC (OFL, license alongside) through the app-fonts seam and proves the rendered string is real ideograph outlines — compared against both the bundled face's rendering and the same registered face's own uncovered-string fallback, so tofu from any face fails the receipt.
|
||||
- **Registered-image memory is on-demand**: each registered canvas image slot buffer is one lazy 1 MiB allocation from `Runtime.Options.allocator` at the slot's first registration (freed by `Runtime.deinit`; unregister/register churn reuses buffers, so the footprint stays bounded by the high-water slot count), so a runtime that never registers an image no longer carries the former 16 MiB embedded pixel pool.
|
||||
- **New error on register**: `registerCanvasImage` / `registerCanvasImageBytes` (and the `fx.registerImage` / `fx.registerImageBytes` bindings) now surface `error.OutOfMemory` when a slot's pixel buffer cannot be allocated — the refusal happens before any registry mutation, so the registry is unchanged and the same registration can be retried once memory recovers.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Large markup documents compile in the compiled engine**: `CompiledMarkupView` / `CompiledMarkupImports` no longer fail with "evaluation exceeded 1000 backwards branches" on `.native` documents past ~10KB — documents now carry their source size from parse/resolve time, so the comptime canonicalize pass sizes its branch quota in O(1) instead of re-measuring the tree inside the quota argument (which ran under the caller's default budget).
|
||||
- **Linux `native dev` no longer crashes at startup**: Debug builds on x86_64 Linux segfaulted creating the first shell view (a general protection fault in the GTK host's `native_sdk_gtk_create_view`) because Zig 0.16.0's self-hosted x86_64 backend — the Debug default — mis-places stack-passed arguments in the host call's long mixed signature; the app executable now forces the LLVM backend on x86_64 like every other artifact in the build graph, and the ejected template's build does the same.
|
||||
- **GTK host string caps**: the Linux host now refuses view create/update calls whose string lengths exceed the platform caps with a teaching warning instead of copying from a corrupted pointer, so a broken C-ABI boundary fails loudly at the seam.
|
||||
- **Linux Debug scaffold smoke in CI**: a new `linux-dev-smoke` job scaffolds the default template, builds it `-Doptimize=Debug` (the `native dev` mode Release-shaped lanes never exercise), and drives it under Xvfb to the first presented frame.
|
||||
- **Late-registered fonts re-measure open surfaces**: registering a face after views are installed (`runtime.registerCanvasFont` on a live runtime) now rebuilds every installed `UiApp` surface — the main canvas and declared windows — on the next presented frame, so text laid out before the face joined re-measures with the registered face instead of keeping its pre-registration widths under a repaint.
|
||||
- **macOS host font state ends with its runtime**: `Runtime.deinit` now returns each registered id's host-side registration — the CoreText descriptor and its measurement caches, including the measured-width cache the host previously retained until memory pressure — so embedders that cycle runtimes no longer accumulate per-process font state; removal is ownership-token guarded, so an older runtime's teardown never removes a newer runtime's live face under a shared id.
|
||||
- **Breaking**: `PlatformServices.registerGpuSurfaceFont` now returns the host's ownership token for the registration (`u64`; 0 from hosts that retain no per-id state), and the new optional `unregisterGpuSurfaceFont(id, token)` service returns that state at teardown — a deliberate break while the toolkit is pre-1.0, so host font lifetime has an owner. Embedders implementing a custom platform change `register_gpu_surface_font_fn` to return a `u64` token (0 is fine for a stateless accept) and may supply `unregister_gpu_surface_font_fn` to release per-id host state when the registering runtime deinits.
|
||||
- **Ternaries with spread-literal arms compile from TypeScript cores**: `parsed === null ? q : { ...q, state: "ok", price: parsed }` — and the nested, `!==`, both-arms, argument, object-field, and `x === null ? { ...fallback } : x` spellings — no longer emit Zig that reads the null-narrowing capture before it binds (`use of undeclared identifier`) or evaluates both arms unconditionally; arms that build values statement-by-statement now lower into per-branch blocks feeding a typed temp, so exactly the taken arm runs, and pure-arm ternaries keep their tight `if`/`orelse` expression forms.
|
||||
- **Optional switch payloads keep their optional through the capture**: reading a `number | null` payload (directly or via a `const` local) inside its `case` no longer mistypes the value as non-null, which routed `msg.parsed === null ? ... : ...` around the narrowing lowering and emitted `?f64` into `f64` slots.
|
||||
- **Early-exit guards narrow like early returns**: `if (r === null) break;` in a parse loop (and the `continue`, labeled, multi-statement, `throw`-exit, and `if (x !== null) { ... } else { break/return }` spellings) now narrows the optional for the rest of the loop body the way tsc's flow analysis does, instead of emitting Zig field access on the still-optional value; `if (msg.kind !== "num") break;` narrows the union payload the same way.
|
||||
- **Guard narrowing ends with its block**: a guard's captures no longer leak past the loop body or branch they narrow — reads after the construct see the unnarrowed value again (matching tsc, whose exit path may bypass the guard) instead of referencing an out-of-scope Zig capture.
|
||||
- **Early switch-clause breaks stop the build**: an unlabeled `break` that exits a `switch` from inside a clause body now teaches at transpile time — Zig's `break` binds loops, so the old emission jumped past the enclosing loop instead of resuming after the switch.
|
||||
- **A redundant kind guard no longer un-optionals a switch payload**: scoped kind-narrowing now restores the still-optional markers alongside the substitutions it snapshots, so `const marker = msg.kind === "got" ? 1 : 2;` inside `case "got":` no longer leaves a `number | null` payload typed non-null for the rest of the clause (which emitted `if (parsed != null) parsed + marker else 0` — invalid Zig operands on the `?f64`).
|
||||
- **Inferred locals from narrowed ternaries value non-optional**: `const picked = q === null ? { ...fallback, price: 0 } : q;` (either polarity, no `: Quote` annotation) now types the local by the arm the condition narrows — `Quote`, exactly as tsc infers — instead of the raw optional, which declared a `?Quote` temporary that failed Zig compilation at its first non-optional use (`expected type 'Quote', found '?Quote'`).
|
||||
- **A redundant nested `switch` on the same subject hands back the outer capture**: the arm cleanup now repopulates its narrowing maps from the snapshot instead of only deleting the arm's additions, so an inner arm's capture that OVERWROTE the outer arm's entry no longer leaks into the continuation after the inner switch (which emitted the inner capture name after its Zig block had closed — `use of undeclared identifier`).
|
||||
- **Else-if chains keep post-if narrowing**: `if (x === null) return -1; else if (flag) { n = 2; } return x.v + n;` — and the else-if-else, chained else-if-else-if, and `!==`-polarity spellings — now narrow `x` after the statement like the plain-else form does; the else-if emission path returned before applying the post-if narrowing, so the fall-through read landed on the still-optional value (`optional type does not support field access`).
|
||||
- **Reassigned `let` bindings never fuse into a `const`**: `let p = next(i); if (p === null) continue; p = { ...p, v: 10 };` no longer fuses the declaration and guard into `const p = next(i) orelse continue;` (Zig: `cannot assign to constant`); the binding stays a `var`, the guard keeps its plain null test, and later reads unwrap the live variable — which the assignment path keeps narrowed across provably non-null writes.
|
||||
- **A branch that widens a narrowed optional stays widened past the merge**: `if (p === null) return -1; if (flag) { p = null; } if (p === null) return 0;` — the branch-exit restore that keeps narrowing CONTAINED (additions inside a branch die at its exit) no longer also resurrects a narrow the branch killed by assigning null (or a fresh optional-returning call); kills now re-apply after every branch, switch-arm, and kind-guard exit and propagate through nested blocks, so the post-merge re-check tests the live value instead of emitting `p.? == null` (Zig: `comparison of 'f64' with null`). The merge is conservative — a kill on any path that can reach the merge drops the narrow, and the re-check tsc demands anyway always compiles.
|
||||
- **Compound-guard branches keep those kills dead too**: `if (r !== null && r > 0) { p = null; }` — where the branch emits under the chain's `.?` substitutions — no longer resurrects p's killed narrow when that substitution scope restores its snapshot (its restore ran after the branch re-applied the kill); the scope now rides the same kill-frame protocol as branch and switch-arm exits, and so does the chain-condition emitter, so every full-map narrowing restore in the emitter re-deletes killed entries on exit.
|
||||
- **A kill on an always-exiting branch stays off the surviving flow**: `if (p.v < 0) { p = null; return -1; } return p.v;` inside a null guard — tsc keeps `p` narrowed at the second return because the killing branch left the function, and the emitter now agrees: a branch that always returns (or throws uncaught) drops its kills at the merge instead of deleting the narrow the surviving read depends on (which emitted field access straight onto the `?P`). Kills on paths that resume inside the function — fall-through arms, `break`/`continue` guards in loops, throws caught by an enclosing `try` — still merge outward and drive the post-merge re-check.
|
||||
- **A guard in a lifted callback covers the trailing return it precedes**: `xs.map((p) => { if (p === null) throw bad; return p.v; })` lifts the callback as its statement prefix plus the trailing return's expression, and the prefix's narrowing scope closed before that expression emitted — the read landed on the raw `?P` (`optional type does not support field access`); prefix and trailing expression now share one flow scope, and the scope still closes before the callback's siblings in the emitted loop body.
|
||||
- **A do-while body guard covers the trailing test it flows into**: `do { if (p === null) return -1; n += p.v; } while (p.v > 0);` — tsc evaluates the condition after the body, under the body's flow state, but the body's narrowing scope closed before the lowered `if (!(cond)) break;` emitted, so the test read the raw `?P` (`optional type does not support field access`); the body and the trailing test now share one narrowing scope, restored at the loop boundary. A guard read only by the test binds its capture too, `continue`-carried kills still widen the hoisted first-pass test onto the live optional, and `break`-carried kills still land only on the post-loop state.
|
||||
- **Canvas-app hosts compile silently without a WebView SDK**: the informational `#pragma message` in the GTK host's WebKitGTK stub path (and its Windows WebView2 twin) is gone — zig renders every clang diagnostic of a failing C compile as `error:`, so on machines where a real, unrelated compile error occurred (for example a too-old GTK), the note itself surfaced as the first build-killing error and masked the actual cause; the stub is the expected state of every canvas app and now compiles with zero diagnostics, while a genuinely misconfigured web build still fails loudly via `#error`.
|
||||
- **GTK host compiles against GLib 2.72**: the host now spells "no application flags" in a way that compiles on GLib older than 2.74, so distros that backport GTK 4.10 onto a GLib 2.72 base (Ubuntu 22.04-derived) build canvas apps out of the box.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @codehz
|
||||
- @nextpointer
|
||||
- @perminder-klair
|
||||
|
||||
## 0.5.3
|
||||
|
||||
### New Features
|
||||
|
||||
- **`<media-surface>` — the dynamic texture channel**: a new element compositing textures produced outside the widget tree (video decoders, camera pipelines, external renderers like mpv) into the layout like any widget, with a stable Zig-tier producer API (`runtime.acquireMediaSurfaceProducer`) pushing RGBA8 frames from any thread — latest-wins, damage-tracked, paced by the compositor's presented-frame clock.
|
||||
- **Pushes wake an idle compositor**: a push staging new bytes requests one coalesced frame through the platform's thread-safe cross-thread frame request (the automation watcher's wake path), so video keeps playing in an idle demand-driven app and a late-starting producer is adopted promptly; damage-skipped and stale-handle pushes wake nothing, and teardown disarms the binding under the same fence the wake call holds, so an orphaned producer can never wake a dead host.
|
||||
- **Markup, both engines, and tooling**: `surface="{binding}"` binds the model-owned u64 surface id in the runtime-image-id grammar (binding-only, required, media-surface-scoped with teaching errors), wired through the validator, the compiled and interpreter engines, `native check`'s model contract, LSP hover docs, and a docs component page plus a "Media Producers" recipe.
|
||||
- **Deterministic by policy**: texture contents are presentation chrome — goldens, reference screenshots, and session fingerprints see only the surface's id-derived placeholder, so a session recorded with a live producer replays fingerprint-identical with no producer attached; live GPU hosts composite the real texture through the existing image upload pipeline.
|
||||
- **Reserved id namespace**: bit 63 of the ImageId space now belongs to media-surface textures; `registerCanvasImage` rejects ids with it set (`error.InvalidImageId`) so producer textures and registered images can never collide.
|
||||
- **Cover fit stays inside the frame**: a `cover`-fit media surface carries the image widget's rectangular clip around its texture draw, so the fit-expanded texture can never paint over siblings on hosts that only mask corner radii.
|
||||
- **Adopted-texture memory is on-demand**: each channel's texture buffer is one lazy frame-budget allocation from the new `Runtime.Options.allocator` at first adoption (freed by the new `Runtime.deinit`), so a runtime with no media producers carries zero media-texture bytes — an embedded pool would have put 32 MiB in every Runtime (measured on the docs wasm preview host: 169.5 MB → 137.5 MB per component tile). The allocator freezes into the runtime at init: mutating `options.allocator` on a live runtime never retargets ownership, so allocations and their frees always pair on one allocator.
|
||||
- **Trackpad pinch reaches apps**: pinch-to-zoom now flows from the macOS host (`magnifyWithEvent:`) through phase-explicit `pinch_begin`/`pinch_change`/`pinch_end` input events into a view-global app channel — Zig cores declare `Options.on_pinch`, TypeScript cores export `pinchMsg(pinch)` with `PinchPhase`/`PinchEvent` in `@native-sdk/core/events`; each change carries a multiplicative delta (cumulative gesture scale is the product of `1 + scale`, applied memorylessly — `zoom *= 1 + scale`) and the pointer anchor rides view-local `x`/`y` (the pointer location during the gesture — zoom-at-cursor anchoring, not a between-the-fingers midpoint); every event names its source window and view (`window_id`/`label` in Zig, `windowId`/`label` in TypeScript), so multi-window apps tell pinches apart; a terminal Ended/Cancelled event that still measured a nonzero delta arrives as one last change before the end, so the product always matches what the OS reported. On macOS the delta is AppKit's raw per-event `NSEvent.magnification`, forwarded untransformed: raw magnification IS the multiplicative per-event delta — the convention every browser engine ships — so the product of `1 + scale` is the zoom users already experience for the same gesture in Safari and Chrome. The one guard is a per-event floor: a single event's magnification at or below -1 (a zoom inverting through zero scale — physically impossible, only a driver glitch could report it) clamps just above -1, so every emitted factor stays positive. Windows precision-touchpad and GTK gesture sources are staged follow-ups.
|
||||
- **`widget-pinch` automation verb**: `native automate widget-pinch <view-label> <scale> [x y]` drives the real pinch event stream without a trackpad (`<scale>` is the gesture's FINAL multiplicative zoom — one change carrying `scale - 1`, anchor point defaulting to the view center), journaled like every synthesized input so recorded sessions replay the identical zoom. Automation protocol bumps to v7.
|
||||
- **Session journal v5**: gpu-surface input records gain the pinch `scale` field and the pinch kinds; readers refuse v4 journals loudly in both directions, per the format's skew discipline — re-record sessions with this build.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Registered CJK fonts render dense glyphs**: glyph outline budgets are now sized from real CJK faces (Noto Sans JP/SC/TC/KR measured; 1024 points / 128 contours per glyph), so everyday dense kanji like 鬱 ink as real outlines instead of notdef blocks; the per-font registration size bound rose to 24 MiB so full CJK faces register.
|
||||
- **Glyph complexity validates at registration, not render**: a face whose `maxp` declares glyphs denser than the outline budgets — simple maxima and flattened-composite maxima (`maxCompositePoints`/`maxCompositeContours`) alike — is refused at registration with a teaching that names its numbers against the budgets (`error.FontExceedsGlyphBudgets`), instead of silently degrading individual glyphs to blocks at render time.
|
||||
- **Font bytes are heap-allocated on demand**: the registry copies each registered file into an exact-size allocation from the runtime's new `Options.allocator` (freed by the new `Runtime.deinit`), so the 24 MiB bound is validation, not a storage reservation — a runtime with no registered fonts carries zero font bytes, where a reservation-shaped pool would have embedded 192 MiB in every Runtime (measured on the docs wasm preview host: 313.5 MB → 121.5 MB per component tile).
|
||||
- **`EmbeddedApp.deinit` completes the embed lifecycle**: direct embedders end an embedded app with `defer embedded.deinit()` (idempotent), which returns the runtime's heap-owned registrations — without it, a host creating and destroying apps in one process leaked the registered font storage per cycle. The wrapper hosts and the C ABI's `native_sdk_app_destroy` route their teardown through the same deinit, one lifecycle owner.
|
||||
- **Glyph raster budgets are derived from the registration gate**: the vector core's glyph-fill capacities (`GlyphRasterizer`: 18,560 edges/crossings, flattening clamped at 16 segments per curve) are computed from the outline budgets registration admits, so a truthfully-declared budget-maximal glyph — a 1024-point zigzag contour included — rasterizes at any size instead of hitting `VectorPathTooComplex` and degrading to the block fallback the gate promises cannot happen; the clamp binds only above ~128-px ems and keeps the polyline within 0.2% of the em, so existing renders are byte-identical.
|
||||
- **Single-line fields never hold or paint line breaks**: pasting multi-line text into an input, text field, search field, or combobox now strips the line breaks at the edit seam (the HTML value-sanitization rule — lines join with nothing between them), covering clipboard paste from the shortcut and the context menu, typed and automation `text_input`, and IME composition, with the app's `on_input` hearing the same sanitized bytes the editor applied; a paste of only newlines inserts nothing.
|
||||
- **Defensive render containment**: a single-line value that still holds a `\n` (a model-set value, an old journal) now paints as one line — breaks present as spaces — under a forced content-rect clip, so text can never escape the field's rounded border on any renderer.
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @IFTC-XLKJ
|
||||
- @WhiteHades
|
||||
- @jhodges10
|
||||
|
||||
## 0.5.2
|
||||
|
||||
### New Features
|
||||
|
||||
- **Anchored tooltips with hover intent**: `<tooltip anchor="above|below">` beside its trigger in a stack floats against the trigger and hands visibility to the runtime — it shows after the pointer has rested on the trigger for the show delay (default 600ms) and hides on leave, so sweeping a toolbar flashes nothing; after a pointer-hovered tooltip hides on leave, a shared 400ms warm window shows the next trigger's tooltip instantly (the other hide causes below — focus moving on, Escape, a press, view blur — deliberately open no warm window). Delay, warm window, and the behaviors below match shadcn/ui's defaults (Base UI). The model never hears hover, both engines lower it identically, and every transition steps on the recorded input/frame clock, so recorded hover-dwell sessions replay their show/hide frames byte-identically.
|
||||
- **`tooltip-delay` attribute**: per-tooltip show delay in milliseconds (`0` shows the instant the trigger is hovered); absent follows the new `tooltip_show_delay_ms`/`tooltip_warm_window_ms` metric tokens. Registry attr code 80; static (non-anchored) tooltips keep their classic paint-when-rendered behavior.
|
||||
- **Keyboard focus reveals immediately**: tabbing onto a tooltip-owning trigger shows its tooltip with no dwell (keyboard navigation is deliberate, and content revealed on hover or focus must not depend on pointer timing); focus moving on or Escape hides it just as immediately, without warming the pointer's skip window.
|
||||
- **Hoverable content**: a shown tooltip's own bounds hold it open (WCAG 1.4.13; Base UI's `hoverable` default) — the pointer can cross the anchor gap into the tooltip along a bounded safe-polygon corridor, and it hides only after leaving both the trigger and the tooltip (with the usual warm window); the corridor resolves on the recorded frame clock, so replays stay byte-identical.
|
||||
- **Scroll steps the machine**: every scroll path (wheel, kinetic steps, native drivers, keyboard scrolling) routes its hover change through the same intent transition a pointer move takes — a trigger scrolled out from under the pointer disarms/hides, and a trigger scrolled under it arms per normal.
|
||||
- **Press dismisses**: ANY pointer-down — primary or secondary, including downs consumed by the context-menu gesture or a window-drag region — or Space/Enter on the focused trigger cancels a pending reveal, dismisses a shown tooltip, and closes the warm window, so an activated control never re-explains itself on the post-click hover. Keyboard activation and Escape also spend the standing focus reveal: a rebuild that replaces or rekeys the tooltip (the activation's own model update, typically) cannot resurrect it — it stays down while focus rests on the trigger, until the keyboard genuinely leaves and returns; the focus ring stays painted, and a later pointer hover re-earns the dwell normally.
|
||||
- **Rebuild hygiene**: a rebuild that removes, rekeys, disables, or re-parents a tooltip's trigger resets that tooltip's armed/shown/warm state and re-stamps it hidden, even when the tooltip node itself survives.
|
||||
- **View blur resets**: a canvas view that loses focus (to a sibling view or with the window) drops its entire tooltip conversation — armed delay, shown tooltip (keyboard- and pointer-owned), warm window — and re-stamps hidden, so no stale tooltip floats in a view the keyboard left. The window key-loss reset fires on the flag's own focused→unfocused edge, so it holds however the host announces the change: one gain event (macOS) or loss-before-gain (Windows, GTK), including a loss with no gain at all (every window inactive).
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Search empty state names its scope**: the system monitor's no-match state now says search only sees the top 128 processes by CPU, so a miss on a quiet process reads as scope, not absence (both tiers).
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Dark-scheme accent focus rings settle down — and stay visible**: a `theme_accent` (or `canvas.accentOverrides`) now derives its dark-appearance focus ring at half the accent's saturation instead of the raw brand hue, contrast-floored at 3:1 (WCAG non-text) against the lightest dark tone controls commonly sit on (the house muted surface `#262626` — rings draw outside controls, so clearing the lightest adjacent container tone clears the page background and card surface too, in both shipped packs) — desaturation alone can cost a deep accent the bar it cleared (`#008000` fell from 3.9:1 to ~2.6:1 on the background; the floor lifts it back over 3:1 on background and card surface alike) — so the soundboard search field's ring no longer glares neon in dark mode; `canvas.accentFocusRing` exports the derivation so hand-authored token sets (the Zig soundboard's theme) state the identical ring.
|
||||
- **Breaking**: `canvas.accentOverrides` now takes the resolved `ColorScheme` alongside the accent — a deliberate break while the toolkit is pre-1.0, so the one function under the natural name states the scheme it layers over; pass `.light` to reproduce the previous output exactly.
|
||||
- **Escape in a search field now reaches your core**: Escape's clear (and its composition cancel) was a runtime-local editor operation — the field emptied on screen while the model kept the stale query and the list stayed filtered. Every keyboard-driven editor mutation now derives ONE edit that is applied to the retained editor AND stamped onto the dispatched event, so `on-input` hears Escape exactly like typing, paste, and the clear affordance — on both authoring tiers, and byte-identically under record→replay.
|
||||
- **Automation and accessibility composition verbs ride the real input path**: `widget-action set_composition/commit_composition/cancel_composition` now dispatch the same ime input events a live IME session produces (journaled, mirrored to the core), and `set_selection` reaches the core's selection mirror through the stamped-edit channel.
|
||||
- **Accessibility actions replay without double-dispatch**: a journaled assistive action (press, toggle, set_text, drag, ...) no longer also journals the key/text events its verb synthesizes — replaying the action re-derives them, so recorded AX-driven sessions replay each input exactly once instead of twice.
|
||||
- **Direct verb calls journal outer-wins too**: an embed host's `widgetAction` and automation `widget_action` commands now record the same single `widget_accessibility_action` record the platform accessibility path does (the enum gained the composition kinds), so replay re-runs the verb — focus included — instead of delivering its untargeted key/ime children to whichever field the session happened to leave focused.
|
||||
- **Grids keep their declared column slots**: children fewer than a grid's declared `columns` now keep the column-slot width and fill the leading slots, instead of stretching across the freed row — a search that narrows the soundboard album grid below its column count leaves image-forward tiles at their natural size.
|
||||
- **"terminate request delivered" retires itself**: the system monitor's delivery notice now clears on the next applied sample instead of sitting in the footer forever; failure notes keep sticking (both tiers).
|
||||
- **System monitor footer says UTC**: the sample-time stamp renders from the journaled clock in UTC, and the footer now labels it "UTC" instead of passing it off as local time — local rendering would need a journaled timezone channel to stay replay-byte-identical, so the label is the honest fix (both the Zig example and the TS port).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @marcusschiesser
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Improvements
|
||||
|
||||
- **The TS scaffold's status bar earns its empty state**: a fresh scaffold said "stamped: -1ms" until the first Stamp press; the template's markup now branches on `{stampedMs < 0}` and says "press Stamp for a timestamp" instead — teaching the if/else markup shape in the starter while it's at it.
|
||||
@@ -26,8 +552,6 @@ All notable changes to the Native SDK (formerly zero-native) will be documented
|
||||
|
||||
- @ctate
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -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,21 +93,24 @@ 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
|
||||
|
||||
macOS is the primary development platform and carries the deepest support: Metal presentation, OS scroll physics, native context menus, app menus, tray, and dialogs. Linux runs the full showcase through the deterministic software renderer in real windows, with pointer, keyboard, scroll, IME composition, and HiDPI; Windows runs on a Win32 host with IME composition and is exercised in CI, including real input injection. Mobile support is experimental: iOS is simulator-proven through the embed library and Android cross-compiles with the full embed ABI, but APIs and tooling on both are still evolving — desktop is the mature surface. WebView surfaces coexist on every desktop platform. The [platform support matrix](https://native-sdk.dev/platform-support) documents exactly what each host supports today.
|
||||
macOS is the primary development platform and carries the deepest support: Metal presentation, OS scroll physics, native context menus, app menus, tray, and dialogs. Linux runs the full showcase through the deterministic software renderer in real windows, with pointer, keyboard, scroll, native context menus, IME composition, and HiDPI; Windows runs on a Win32 host with native context menus and IME composition and is exercised in CI, including real input injection. Mobile support is experimental: iOS is simulator-proven through the embed library and Android cross-compiles with the full embed ABI, but APIs and tooling on both are still evolving — desktop is the mature surface. WebView surfaces coexist on every desktop platform. The [platform support matrix](https://native-sdk.dev/platform-support) documents exactly what each host supports today.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
.fingerprint = 0xc309966142f33087,
|
||||
.version = "0.1.0",
|
||||
.minimum_zig_version = "0.16.0",
|
||||
// No dependencies. The terminal emulator (libghostty-vt) is
|
||||
// deliberately NOT pinned here: a pin in this file is materialized
|
||||
// into every consumer's package directory even when lazy and
|
||||
// unused. Apps that want live `<terminal>` sessions pin it in their
|
||||
// own build.zig.zon and ask for it with
|
||||
// `addAppArtifacts(.{ .terminal_sessions = true })` — see
|
||||
// examples/workbench, which also owns that path's test coverage.
|
||||
.dependencies = .{},
|
||||
.paths = .{
|
||||
"README.md",
|
||||
@@ -20,6 +27,7 @@
|
||||
"src",
|
||||
"templates",
|
||||
"tests",
|
||||
"third_party/sqlite",
|
||||
"tools",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,2 +0,0 @@
|
||||
fix: **Dark-scheme accent focus rings settle down — and stay visible**: a `theme_accent` (or `canvas.accentOverrides`) now derives its dark-appearance focus ring at half the accent's saturation instead of the raw brand hue, contrast-floored at 3:1 (WCAG non-text) against the lightest dark tone controls commonly sit on (the house muted surface `#262626` — rings draw outside controls, so clearing the lightest adjacent container tone clears the page background and card surface too, in both shipped packs) — desaturation alone can cost a deep accent the bar it cleared (`#008000` fell from 3.9:1 to ~2.6:1 on the background; the floor lifts it back over 3:1 on background and card surface alike) — so the soundboard search field's ring no longer glares neon in dark mode; `canvas.accentFocusRing` exports the derivation so hand-authored token sets (the Zig soundboard's theme) state the identical ring.
|
||||
- **Breaking**: `canvas.accentOverrides` now takes the resolved `ColorScheme` alongside the accent — a deliberate break while the toolkit is pre-1.0, so the one function under the natural name states the scheme it layers over; pass `.light` to reproduce the previous output exactly.
|
||||
@@ -1,4 +0,0 @@
|
||||
fix: **Escape in a search field now reaches your core**: Escape's clear (and its composition cancel) was a runtime-local editor operation — the field emptied on screen while the model kept the stale query and the list stayed filtered. Every keyboard-driven editor mutation now derives ONE edit that is applied to the retained editor AND stamped onto the dispatched event, so `on-input` hears Escape exactly like typing, paste, and the clear affordance — on both authoring tiers, and byte-identically under record→replay.
|
||||
- **Automation and accessibility composition verbs ride the real input path**: `widget-action set_composition/commit_composition/cancel_composition` now dispatch the same ime input events a live IME session produces (journaled, mirrored to the core), and `set_selection` reaches the core's selection mirror through the stamped-edit channel.
|
||||
- **Accessibility actions replay without double-dispatch**: a journaled assistive action (press, toggle, set_text, drag, ...) no longer also journals the key/text events its verb synthesizes — replaying the action re-derives them, so recorded AX-driven sessions replay each input exactly once instead of twice.
|
||||
- **Direct verb calls journal outer-wins too**: an embed host's `widgetAction` and automation `widget_action` commands now record the same single `widget_accessibility_action` record the platform accessibility path does (the enum gained the composition kinds), so replay re-runs the verb — focus included — instead of delivering its untargeted key/ime children to whichever field the session happened to leave focused.
|
||||
@@ -1 +0,0 @@
|
||||
fix: **Grids keep their declared column slots**: children fewer than a grid's declared `columns` now keep the column-slot width and fill the leading slots, instead of stretching across the freed row — a search that narrows the soundboard album grid below its column count leaves image-forward tiles at their natural size.
|
||||
@@ -1 +0,0 @@
|
||||
improvement: **Search empty state names its scope**: the system monitor's no-match state now says search only sees the top 128 processes by CPU, so a miss on a quiet process reads as scope, not absence (both tiers).
|
||||
@@ -1 +0,0 @@
|
||||
fix: **"terminate request delivered" retires itself**: the system monitor's delivery notice now clears on the next applied sample instead of sitting in the footer forever; failure notes keep sticking (both tiers).
|
||||
@@ -1 +0,0 @@
|
||||
fix: **System monitor footer says UTC**: the sample-time stamp renders from the journaled clock in UTC, and the footer now labels it "UTC" instead of passing it off as local time — local rendering would need a journaled timezone channel to stay replay-byte-identical, so the label is the honest fix (both the Zig example and the TS port).
|
||||
@@ -1,8 +0,0 @@
|
||||
feature: **Anchored tooltips with hover intent**: `<tooltip anchor="above|below">` beside its trigger in a stack floats against the trigger and hands visibility to the runtime — it shows after the pointer has rested on the trigger for the show delay (default 600ms) and hides on leave, so sweeping a toolbar flashes nothing; after a pointer-hovered tooltip hides on leave, a shared 400ms warm window shows the next trigger's tooltip instantly (the other hide causes below — focus moving on, Escape, a press, view blur — deliberately open no warm window). Delay, warm window, and the behaviors below match shadcn/ui's defaults (Base UI). The model never hears hover, both engines lower it identically, and every transition steps on the recorded input/frame clock, so recorded hover-dwell sessions replay their show/hide frames byte-identically.
|
||||
- **`tooltip-delay` attribute**: per-tooltip show delay in milliseconds (`0` shows the instant the trigger is hovered); absent follows the new `tooltip_show_delay_ms`/`tooltip_warm_window_ms` metric tokens. Registry attr code 80; static (non-anchored) tooltips keep their classic paint-when-rendered behavior.
|
||||
- **Keyboard focus reveals immediately**: tabbing onto a tooltip-owning trigger shows its tooltip with no dwell (keyboard navigation is deliberate, and content revealed on hover or focus must not depend on pointer timing); focus moving on or Escape hides it just as immediately, without warming the pointer's skip window.
|
||||
- **Hoverable content**: a shown tooltip's own bounds hold it open (WCAG 1.4.13; Base UI's `hoverable` default) — the pointer can cross the anchor gap into the tooltip along a bounded safe-polygon corridor, and it hides only after leaving both the trigger and the tooltip (with the usual warm window); the corridor resolves on the recorded frame clock, so replays stay byte-identical.
|
||||
- **Scroll steps the machine**: every scroll path (wheel, kinetic steps, native drivers, keyboard scrolling) routes its hover change through the same intent transition a pointer move takes — a trigger scrolled out from under the pointer disarms/hides, and a trigger scrolled under it arms per normal.
|
||||
- **Press dismisses**: ANY pointer-down — primary or secondary, including downs consumed by the context-menu gesture or a window-drag region — or Space/Enter on the focused trigger cancels a pending reveal, dismisses a shown tooltip, and closes the warm window, so an activated control never re-explains itself on the post-click hover. Keyboard activation and Escape also spend the standing focus reveal: a rebuild that replaces or rekeys the tooltip (the activation's own model update, typically) cannot resurrect it — it stays down while focus rests on the trigger, until the keyboard genuinely leaves and returns; the focus ring stays painted, and a later pointer hover re-earns the dwell normally.
|
||||
- **Rebuild hygiene**: a rebuild that removes, rekeys, disables, or re-parents a tooltip's trigger resets that tooltip's armed/shown/warm state and re-stamps it hidden, even when the tooltip node itself survives.
|
||||
- **View blur resets**: a canvas view that loses focus (to a sibling view or with the window) drops its entire tooltip conversation — armed delay, shown tooltip (keyboard- and pointer-owned), warm window — and re-stamps hidden, so no stale tooltip floats in a view the keyboard left. The window key-loss reset fires on the flag's own focused→unfocused edge, so it holds however the host announces the change: one gain event (macOS) or loss-before-gain (Windows, GTK), including a loss with no gain at all (every window inactive).
|
||||
@@ -4,3 +4,4 @@ next-env.d.ts
|
||||
.next-gate/
|
||||
.next-agent/
|
||||
.next-check/
|
||||
.next-final/
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import createMDX from "@next/mdx";
|
||||
import { createRequire } from "node:module";
|
||||
import { readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Resolve the plugin to an absolute path (still a string, so the config
|
||||
// stays serializable for Turbopack). A bare "remark-gfm" is require()d
|
||||
@@ -7,6 +10,19 @@ import { createRequire } from "node:module";
|
||||
// module isolation cannot see this app's dependencies — production
|
||||
// builds resolved it, the Turbopack dev server did not.
|
||||
const require = createRequire(import.meta.url);
|
||||
const docsContentDir = fileURLToPath(new URL("./src/app/docs", import.meta.url));
|
||||
|
||||
function docsSlugs(dir = docsContentDir, segments = []) {
|
||||
const slugs = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
slugs.push(...docsSlugs(path.join(dir, entry.name), [...segments, entry.name]));
|
||||
} else if (entry.name === "page.mdx" && segments.length > 0) {
|
||||
slugs.push(segments.join("/"));
|
||||
}
|
||||
}
|
||||
return slugs;
|
||||
}
|
||||
|
||||
const withMDX = createMDX({
|
||||
options: {
|
||||
@@ -30,9 +46,26 @@ const nextConfig = {
|
||||
ignored: ["**/.next-gate/**", "**/.next-check/**"],
|
||||
},
|
||||
async redirects() {
|
||||
// Config redirects preserve the request query string. Keeping these out
|
||||
// of the prerendered catch-all route avoids baking a query-less Location
|
||||
// header into every legacy URL's static response.
|
||||
const legacyDocsRedirects = docsSlugs().flatMap((slug) => [
|
||||
{ source: `/${slug}`, destination: `/docs/${slug}`, permanent: true },
|
||||
{ source: `/${slug}.md`, destination: `/docs/${slug}.md`, permanent: true },
|
||||
{ source: `/md/${slug}`, destination: `/docs/${slug}.md`, permanent: true },
|
||||
]);
|
||||
|
||||
return [
|
||||
// The Philosophy page became the Introduction, the opening page of the docs.
|
||||
{ source: "/philosophy", destination: "/introduction", permanent: true },
|
||||
{ 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,
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "pnpm typecheck && pnpm build && node scripts/check-code-toggle.mjs"
|
||||
"check": "pnpm typecheck && pnpm build && node scripts/check-doc-routes.mjs && node scripts/check-code-toggle.mjs && node scripts/check-wasm-preview.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdx-js/loader": "^3",
|
||||
@@ -41,4 +41,4 @@
|
||||
"postcss@<8.5.10": ">=8.5.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 854 B After Width: | Height: | Size: 1010 B |
|
Before Width: | Height: | Size: 864 B After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 302 B |
|
After Width: | Height: | Size: 328 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,203 @@
|
||||
// SEO regression gate for the /docs migration. Every page.mdx must build at
|
||||
// one canonical HTML URL and one canonical Markdown sibling; the former route
|
||||
// must redirect permanently rather than render duplicate content. Canonical
|
||||
// metadata, sitemap entries, llms.txt, and rendered internal links must all
|
||||
// point directly into /docs so crawlers never have to choose between copies.
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join, relative, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const docsDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourceDir = join(docsDir, "src", "app", "docs");
|
||||
const distDir = join(docsDir, process.env.NEXT_DIST_DIR || ".next");
|
||||
const appOutputDir = join(distDir, "server", "app");
|
||||
const siteUrl = "https://native-sdk.dev";
|
||||
|
||||
function* mdxPages(dir) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) yield* mdxPages(full);
|
||||
else if (entry === "page.mdx") yield full;
|
||||
}
|
||||
}
|
||||
|
||||
function readRequired(file, route) {
|
||||
if (!existsSync(file)) {
|
||||
throw new Error(`${route}: missing build output ${file}`);
|
||||
}
|
||||
return readFileSync(file, "utf8");
|
||||
}
|
||||
|
||||
const routesManifest = JSON.parse(
|
||||
readRequired(join(distDir, "routes-manifest.json"), "redirect manifest"),
|
||||
);
|
||||
|
||||
function assertConfiguredRedirect(source, destination) {
|
||||
const redirect = routesManifest.redirects.find((candidate) => candidate.source === source);
|
||||
if (!redirect || redirect.statusCode !== 308 || redirect.destination !== destination) {
|
||||
throw new Error(
|
||||
`${source}: expected a query-preserving 308 config redirect to ${destination}, got ${JSON.stringify(redirect)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function proseLines(markdown) {
|
||||
const lines = [];
|
||||
let fence = null;
|
||||
for (const line of markdown.split("\n")) {
|
||||
const marker = line.trimStart().match(/^(```|~~~)/)?.[1];
|
||||
if (marker) {
|
||||
if (fence === marker) fence = null;
|
||||
else if (fence === null) fence = marker;
|
||||
continue;
|
||||
}
|
||||
if (fence === null) lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function headings(markdown) {
|
||||
return proseLines(markdown)
|
||||
.map((line) => line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/))
|
||||
.filter(Boolean)
|
||||
.map((match) => `${match[1]} ${match[2]}`);
|
||||
}
|
||||
|
||||
function assertCleanMarkdown(source, markdown, route) {
|
||||
// Generic type spellings such as `Sub<Msg>` are legitimate inside inline
|
||||
// code and must not be mistaken for unresolved MDX components.
|
||||
const prose = proseLines(markdown).join("\n").replace(/`[^`\n]*`/g, "");
|
||||
const unresolved = prose.match(/<[A-Z][A-Za-z0-9]*(?:\s|\/?>)/)?.[0];
|
||||
if (unresolved) {
|
||||
throw new Error(`${route}: unresolved MDX component in Markdown output: ${unresolved}`);
|
||||
}
|
||||
const unresolvedExpression = prose.match(/\{"[^"\\\r\n]*"\}|\{'[^'\\\r\n]*'\}/)?.[0];
|
||||
if (unresolvedExpression) {
|
||||
throw new Error(
|
||||
`${route}: unresolved MDX string expression in Markdown output: ${unresolvedExpression}`,
|
||||
);
|
||||
}
|
||||
|
||||
const renderedHeadings = new Set(headings(markdown));
|
||||
for (const heading of headings(source)) {
|
||||
if (!renderedHeadings.has(heading)) {
|
||||
throw new Error(`${route}: Markdown output dropped source heading ${heading}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const component of source.matchAll(/<EjectSection\s+components=\{\[([\s\S]*?)\]\}\s*\/>/g)) {
|
||||
if (!renderedHeadings.has("## Eject")) {
|
||||
throw new Error(`${route}: EjectSection did not render its heading`);
|
||||
}
|
||||
for (const name of component[1].matchAll(/"([^"]+)"/g)) {
|
||||
const command = `native eject component ${name[1]}`;
|
||||
if (!markdown.includes(command)) {
|
||||
throw new Error(`${route}: EjectSection did not render command ${command}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pageFiles = [...mdxPages(sourceDir)];
|
||||
const canonicalPaths = new Set(
|
||||
pageFiles.map((page) => {
|
||||
const slug = relative(sourceDir, dirname(page)).split(sep).join("/");
|
||||
return `/docs/${slug}`;
|
||||
}),
|
||||
);
|
||||
const canonicalUrls = new Set([...canonicalPaths].map((route) => `${siteUrl}${route}`));
|
||||
const canonicalMarkdownUrls = new Set(
|
||||
[...canonicalPaths].map((route) => `${siteUrl}${route}.md`),
|
||||
);
|
||||
let pages = 0;
|
||||
|
||||
for (const page of pageFiles) {
|
||||
pages += 1;
|
||||
const slug = relative(sourceDir, dirname(page)).split(sep).join("/");
|
||||
const canonicalPath = `/docs/${slug}`;
|
||||
const canonicalUrl = `${siteUrl}${canonicalPath}`;
|
||||
const output = join(appOutputDir, "docs", slug);
|
||||
const html = readRequired(`${output}.html`, canonicalPath);
|
||||
const source = readFileSync(page, "utf8");
|
||||
|
||||
if (!html.includes(`<link rel="canonical" href="${canonicalUrl}"/>`)) {
|
||||
throw new Error(`${canonicalPath}: missing its exact canonical link tag`);
|
||||
}
|
||||
if (!html.includes(`<meta property="og:url" content="${canonicalUrl}"/>`)) {
|
||||
throw new Error(`${canonicalPath}: Open Graph URL is not canonical`);
|
||||
}
|
||||
|
||||
for (const match of html.matchAll(/<a\b[^>]*\bhref="([^"]+)"/g)) {
|
||||
const href = match[1];
|
||||
if (href?.startsWith("/") && href !== "/" && !href.startsWith("/docs/")) {
|
||||
throw new Error(`${canonicalPath}: rendered internal link bypasses /docs: ${href}`);
|
||||
}
|
||||
if (href?.startsWith("/docs/")) {
|
||||
const target = href.split(/[?#]/, 1)[0];
|
||||
if (target && !canonicalPaths.has(target)) {
|
||||
throw new Error(`${canonicalPath}: rendered internal link targets no docs page: ${href}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const markdownMeta = JSON.parse(readRequired(`${output}.md.meta`, `${canonicalPath}.md`));
|
||||
if (
|
||||
markdownMeta.status !== 200 ||
|
||||
markdownMeta.headers?.["content-type"] !== "text/markdown; charset=utf-8"
|
||||
) {
|
||||
throw new Error(`${canonicalPath}.md: expected a static text/markdown response`);
|
||||
}
|
||||
if (markdownMeta.headers?.link !== `<${canonicalUrl}>; rel="canonical"`) {
|
||||
throw new Error(`${canonicalPath}.md: missing its exact canonical HTTP Link header`);
|
||||
}
|
||||
const markdown = readRequired(`${output}.md.body`, `${canonicalPath}.md`);
|
||||
assertCleanMarkdown(source, markdown, `${canonicalPath}.md`);
|
||||
|
||||
assertConfiguredRedirect(`/${slug}`, canonicalPath);
|
||||
assertConfiguredRedirect(`/${slug}.md`, `${canonicalPath}.md`);
|
||||
assertConfiguredRedirect(`/md/${slug}`, `${canonicalPath}.md`);
|
||||
|
||||
for (const legacyOutput of [
|
||||
join(appOutputDir, `${slug}.html`),
|
||||
join(appOutputDir, `${slug}.meta`),
|
||||
join(appOutputDir, `${slug}.md.meta`),
|
||||
join(appOutputDir, "md", `${slug}.meta`),
|
||||
]) {
|
||||
if (existsSync(legacyOutput)) {
|
||||
throw new Error(`/${slug}: legacy URL was prerendered instead of using its config redirect`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pages === 0) throw new Error("no docs page.mdx files found");
|
||||
|
||||
const sitemap = readRequired(join(appOutputDir, "sitemap.xml.body"), "/sitemap.xml");
|
||||
const sitemapUrls = new Set([...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]));
|
||||
for (const match of sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)) {
|
||||
const url = match[1];
|
||||
if (url !== `${siteUrl}/` && !canonicalUrls.has(url)) {
|
||||
throw new Error(`/sitemap.xml: non-canonical documentation URL ${url}`);
|
||||
}
|
||||
}
|
||||
for (const url of canonicalUrls) {
|
||||
if (!sitemapUrls.has(url)) throw new Error(`/sitemap.xml: missing canonical page ${url}`);
|
||||
}
|
||||
|
||||
const llms = readRequired(join(appOutputDir, "llms.txt.body"), "/llms.txt");
|
||||
const llmsUrls = new Set();
|
||||
for (const line of llms.split("\n")) {
|
||||
if (!line.startsWith("- [")) continue;
|
||||
const url = line.match(/^- \[[^\]]+\]\(([^)]+)\)$/)?.[1];
|
||||
if (!url || !canonicalMarkdownUrls.has(url)) {
|
||||
throw new Error(`/llms.txt: non-canonical documentation link: ${line}`);
|
||||
}
|
||||
llmsUrls.add(url);
|
||||
}
|
||||
for (const url of canonicalMarkdownUrls) {
|
||||
if (!llmsUrls.has(url)) throw new Error(`/llms.txt: missing canonical page ${url}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`docs route check passed: ${pages} canonical pages, Markdown siblings, and query-preserving legacy redirects verified`,
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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` 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";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const docsDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const wasmPath = join(docsDir, "public", "wasm", "component-preview.wasm");
|
||||
const vocabPath = join(docsDir, "src", "lib", "component-vocab.json");
|
||||
const bytes = readFileSync(wasmPath);
|
||||
const vocab = JSON.parse(readFileSync(vocabPath, "utf8"));
|
||||
const { instance } = await WebAssembly.instantiate(bytes, {});
|
||||
const exports = instance.exports;
|
||||
const sceneNames = ["code", "code-diff"];
|
||||
|
||||
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 truncates code-diff metadata above line 96 on wasm32",
|
||||
);
|
||||
}
|
||||
|
||||
for (const sceneName of sceneNames) {
|
||||
const scene = new TextEncoder().encode(sceneName);
|
||||
const scenePtr = exports.preview_alloc(scene.length);
|
||||
|
||||
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}`);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
|
||||
import { siteUrl } from "@/lib/site";
|
||||
|
||||
/**
|
||||
* Serve every canonical docs page as clean Markdown beside its HTML route.
|
||||
* Legacy URLs are config redirects so Next can preserve each request's query
|
||||
* string; this route stays fully static and only emits the canonical siblings.
|
||||
*/
|
||||
|
||||
export const dynamic = "force-static";
|
||||
export const dynamicParams = false;
|
||||
|
||||
const docsDir = () => path.join(process.cwd(), "src", "app", "docs");
|
||||
|
||||
export async function generateStaticParams(): Promise<{ slug: string[] }[]> {
|
||||
const params: { slug: string[] }[] = [];
|
||||
async function walk(dir: string, slug: string[]): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
await walk(path.join(dir, entry.name), [...slug, entry.name]);
|
||||
} else if (entry.name === "page.mdx" && slug.length > 0) {
|
||||
const markdownSlug = [...slug];
|
||||
markdownSlug[markdownSlug.length - 1] += ".md";
|
||||
params.push({ slug: ["docs", ...markdownSlug] });
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(docsDir(), []);
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: { params: Promise<{ slug: string[] }> }) {
|
||||
const { slug } = await context.params;
|
||||
if (slug[0] !== "docs") return new Response("Not found", { status: 404 });
|
||||
const sourceSlug = slug.slice(1);
|
||||
const filename = sourceSlug.at(-1);
|
||||
if (!filename?.endsWith(".md") || filename === ".md") {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
sourceSlug[sourceSlug.length - 1] = filename.slice(0, -3);
|
||||
const canonicalUrl = `${siteUrl}/docs/${sourceSlug.join("/")}`;
|
||||
const filePath = path.join(docsDir(), ...sourceSlug, "page.mdx");
|
||||
// Static params come from the filesystem walk above, but never follow
|
||||
// a path that escapes src/app/docs.
|
||||
if (!filePath.startsWith(docsDir() + path.sep)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
try {
|
||||
const source = await readFile(filePath, "utf8");
|
||||
return new Response(mdxToCleanMarkdown(source) + "\n", {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
Link: `<${canonicalUrl}>; rel="canonical"`,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ComponentPreview } from "@/components/component-preview";
|
||||
import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
# Markdown
|
||||
|
||||
Renders a markdown string (a GFM subset, pipe tables included) as native widgets through the same text pipeline as every other component — deterministic layout, selectable text. `source` is required and must be one `{binding}`; the element takes no children. Links dispatch `on-link` with the URL as payload (bare URLs autolink), `<details>` blocks toggle through `on-details` plus a model-owned `details-expanded` flag list, and `#123` references linkify through `issue-link-base`.
|
||||
|
||||
<ComponentPreview name="markdown" alt="A markdown document rendered by the engine" caption="headings, emphasis, inline code, lists, links, and a code block" />
|
||||
|
||||
## Markup
|
||||
|
||||
```html
|
||||
<markdown source="{release_notes}" on-link="open_link" issue-link-base="https://github.com/native-sdk/native/issues/"></markdown>
|
||||
```
|
||||
|
||||
## Programmatic construction (Zig)
|
||||
|
||||
The builder is the `canvas.markdown` module, parameterized over the app's Msg type; `on_link` pairs with `Ui.linkMsg(.tag)`.
|
||||
|
||||
```zig
|
||||
const Md = native_sdk.canvas.markdown.Markdown(Msg);
|
||||
|
||||
Md.view(ui, model.release_notes, .{
|
||||
.on_link = Ui.linkMsg(.open_link),
|
||||
.issue_link_base = "https://github.com/native-sdk/native/issues/",
|
||||
})
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
<AttrTable element="markdown" attrs={["source", "on-link", "on-details", "details-expanded", "issue-link-base"]} />
|
||||
@@ -1,59 +0,0 @@
|
||||
import { ComponentPreview } from "@/components/component-preview";
|
||||
import { AttrTable } from "@/components/attr-table";
|
||||
import { CodeToggle } from "@/components/code-toggle";
|
||||
|
||||
# Radio
|
||||
|
||||
The single-choice value control, grouped by a `radio-group` row container. Like [checkbox](/components/checkbox), the label rides the `text` attribute — radio is not a text-bearing element, so text content between the tags is rejected with a teaching error. One model field holds the group's selection: render it with `{a == b}` equalities on each radio's `checked`, and let each radio's `on-toggle` dispatch the Msg that sets the field — the engine never flips state on its own.
|
||||
|
||||
<ComponentPreview name="radio-group" alt="A radio group rendered by the engine" caption="a radio group with one selected and one disabled option" />
|
||||
|
||||
## Markup
|
||||
|
||||
```html
|
||||
<radio-group gap="12">
|
||||
<radio checked="{density == default}" on-toggle="set_default" text="Default" />
|
||||
<radio checked="{density == comfortable}" on-toggle="set_comfortable" text="Comfortable" />
|
||||
<radio checked="{density == compact}" disabled="true" text="Compact" />
|
||||
</radio-group>
|
||||
```
|
||||
|
||||
One field holds the group's choice — a string-literal union in a TypeScript core, an enum in a Zig core — and each radio's arm sets it:
|
||||
|
||||
<CodeToggle>
|
||||
|
||||
```ts
|
||||
// model: { readonly density: "default" | "comfortable" | "compact" }
|
||||
case "set_comfortable":
|
||||
return { ...model, density: "comfortable" };
|
||||
```
|
||||
|
||||
```zig
|
||||
// model: density: enum { default, comfortable, compact } = .default,
|
||||
.set_comfortable => model.density = .comfortable,
|
||||
```
|
||||
|
||||
</CodeToggle>
|
||||
|
||||
## Programmatic construction (Zig)
|
||||
|
||||
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
|
||||
|
||||
```zig
|
||||
ui.el(.radio_group, .{ .gap = 12 }, .{
|
||||
ui.el(.radio, .{ .text = "Default", .checked = model.density == .default, .on_toggle = .set_default }, .{}),
|
||||
ui.el(.radio, .{ .text = "Comfortable", .checked = model.density == .comfortable, .on_toggle = .set_comfortable }, .{}),
|
||||
ui.el(.radio, .{ .text = "Compact", .checked = model.density == .compact, .disabled = true }, .{}),
|
||||
})
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
<AttrTable
|
||||
attrs={[
|
||||
"text",
|
||||
"checked",
|
||||
"disabled",
|
||||
"on-toggle",
|
||||
]}
|
||||
/>
|
||||
@@ -1,65 +0,0 @@
|
||||
import { ComponentPreview } from "@/components/component-preview";
|
||||
import { AttrTable } from "@/components/attr-table";
|
||||
import { CodeToggle } from "@/components/code-toggle";
|
||||
|
||||
# Scroll
|
||||
|
||||
A scroll view: wrap multiple children in a single column inside it. The engine owns wheel, kinetic, and keyboard scrolling and draws the scrollbar while a scroll is in flight; `on-scroll` names a Msg variant with a `canvas.ScrollState` payload — or, in a transpiled TypeScript core, a declared record of the same `offset`/`velocity`/`viewport_extent`/`content_extent` fields, matched by name — that delivers the post-scroll offset and viewport/content extents, so the model can observe position without owning it. Echo the offset into a model field bound as `value` and the model owns the position too: setting the field scrolls the region (the controlled-scroll shape). Scrolling pins at the content edges by default — no rubber-band bounce; kinetic motion stops cleanly at the boundary. `overscroll="rubber_band"` opts one region into bouncing past its edges (both the engine physics and the native macOS scroller honor it), and the `ScrollPhysics.overscroll` design token flips the app-wide default, which per-region values override. `on-reach-end` dispatches a plain Msg when a scroll comes within one viewport of the content end — the infinite-fetch signal, fired once per approach with hysteresis (appending a batch grows the extent and re-arms the next approach). A programmatic jump to the end fires once and never re-arms while the offset stays near the end — re-arming needs a post-scroll observation at least 1.5 viewports from it. Pair with [list](/components/list) for layout-culled rows, or the builder's [virtual list](/components/virtual-list) for dataset-scale windows.
|
||||
|
||||
<ComponentPreview name="scroll" alt="A scroll region rendered by the engine" caption="a fixed-height scroll region; the engine draws the scrollbar during scrolling" />
|
||||
|
||||
## Markup
|
||||
|
||||
```html
|
||||
<scroll height="240" padding="8" on-scroll="log_scrolled">
|
||||
<column gap="2">
|
||||
<list-item on-press="open_entry">Changelog entry 14</list-item>
|
||||
<list-item on-press="open_entry">Changelog entry 13</list-item>
|
||||
<list-item on-press="open_entry">Changelog entry 12</list-item>
|
||||
<list-item on-press="open_entry">Changelog entry 11</list-item>
|
||||
<list-item on-press="open_entry">Changelog entry 10</list-item>
|
||||
<list-item on-press="open_entry">Changelog entry 9</list-item>
|
||||
<list-item on-press="open_entry">Changelog entry 8</list-item>
|
||||
</column>
|
||||
</scroll>
|
||||
```
|
||||
|
||||
The arm receives the scroll state and stores what the model wants to remember — echo `offset` into a field bound as the scroll's `value` and the model owns the position too:
|
||||
|
||||
<CodeToggle>
|
||||
|
||||
```ts
|
||||
import { type ScrollState } from "@native-sdk/core/events";
|
||||
|
||||
export type Msg = /* ... */ | { readonly kind: "log_scrolled"; readonly scroll: ScrollState };
|
||||
// in update:
|
||||
case "log_scrolled":
|
||||
return { ...model, changelog_offset: msg.scroll.offset };
|
||||
```
|
||||
|
||||
```zig
|
||||
// Msg arm: log_scrolled: canvas.ScrollState
|
||||
.log_scrolled => |scroll| model.changelog_offset = scroll.offset,
|
||||
```
|
||||
|
||||
</CodeToggle>
|
||||
|
||||
## Programmatic construction (Zig)
|
||||
|
||||
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically. `on_scroll` pairs with `Ui.scrollMsg(.tag)`; the delivered offset is the value the runtime already applied, so echoing it back never fights the scroll reconcile.
|
||||
|
||||
```zig
|
||||
ui.scroll(.{ .height = 240, .padding = 8, .on_scroll = Ui.scrollMsg(.log_scrolled) }, .{
|
||||
ui.column(.{ .gap = 2 }, .{
|
||||
ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 14"),
|
||||
ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 13"),
|
||||
ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 12"),
|
||||
ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 11"),
|
||||
ui.listItem(.{ .on_press = .open_entry }, "Changelog entry 10"),
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
<AttrTable attrs={["on-scroll", "on-reach-end", "overscroll", "height", "width", "padding"]} />
|
||||
@@ -1,41 +0,0 @@
|
||||
import { ComponentPreview } from "@/components/component-preview";
|
||||
import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
# Textarea
|
||||
|
||||
Multi-line text entry. Like [input](/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](/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.
|
||||
|
||||
<ComponentPreview name="textarea" alt="A textarea rendered by the engine" />
|
||||
|
||||
## Markup
|
||||
|
||||
```html
|
||||
<textarea width="320" height="96" placeholder="Write a release note" text="{draft}" on-input="draft_edited" />
|
||||
```
|
||||
|
||||
## Programmatic construction (Zig)
|
||||
|
||||
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
|
||||
|
||||
```zig
|
||||
ui.el(.textarea, .{
|
||||
.width = 320,
|
||||
.height = 96,
|
||||
.placeholder = "Write a release note",
|
||||
.text = model.draft,
|
||||
.on_input = Ui.inputMsg(.draft_edited),
|
||||
}, .{})
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
<AttrTable
|
||||
attrs={[
|
||||
"text",
|
||||
"placeholder",
|
||||
"disabled",
|
||||
"autofocus",
|
||||
"on-input",
|
||||
"on-submit",
|
||||
]}
|
||||
/>
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ComponentPreview } from "@/components/component-preview";
|
||||
import { AttrTable } from "@/components/attr-table";
|
||||
|
||||
# Tree
|
||||
|
||||
A disclosure-tree container. The rows are ordinary elements (usually [list items](/components/list)) carrying `role="treeitem"`, and every such descendant joins one roving keyboard focus set: Up/Down walk the visible rows, Left collapses a row or moves to its parent, Right expands or moves to the first child, Home/End jump to the edges. Expandable rows bind `expanded` and dispatch `on-toggle`; selection follows each row's `on-press`. The model owns both states — children of a collapsed row are simply not rendered.
|
||||
|
||||
<ComponentPreview name="tree" alt="A file tree with an expanded folder and a selected row rendered by the engine" caption="an expanded folder, indented children, and a collapsed sibling" />
|
||||
|
||||
## Markup
|
||||
|
||||
```html
|
||||
<tree width="340" gap="2">
|
||||
<list-item icon="folder-open" role="treeitem" expanded="{src_open}" on-toggle="toggle_src" on-press="select_src">src</list-item>
|
||||
<if test="{src_open}">
|
||||
<row>
|
||||
<column width="20" />
|
||||
<column gap="2" grow="1">
|
||||
<list-item icon="file-text" role="treeitem" selected="{file == main}" on-press="select_main">main.zig</list-item>
|
||||
<list-item icon="file-text" role="treeitem" selected="{file == view}" on-press="select_view">view.zig</list-item>
|
||||
</column>
|
||||
</row>
|
||||
</if>
|
||||
<list-item icon="folder" role="treeitem" expanded="{assets_open}" on-toggle="toggle_assets" on-press="select_assets">assets</list-item>
|
||||
</tree>
|
||||
```
|
||||
|
||||
The indent is plain layout — a fixed-width spacer column beside the children. Omit `expanded` on leaf rows; only rows that bind it participate in Left/Right disclosure.
|
||||
|
||||
## Programmatic construction (Zig)
|
||||
|
||||
In a Zig view, the `canvas.Ui` builder constructs the same tree programmatically:
|
||||
|
||||
```zig
|
||||
ui.tree(.{ .width = 340, .gap = 2 }, .{
|
||||
ui.listItem(.{
|
||||
.icon = "folder-open",
|
||||
.expanded = model.src_open,
|
||||
.on_toggle = .toggle_src,
|
||||
.on_press = .select_src,
|
||||
.semantics = .{ .role = .treeitem },
|
||||
}, "src"),
|
||||
if (model.src_open) ui.row(.{}, .{
|
||||
ui.column(.{ .width = 20 }, .{}),
|
||||
ui.column(.{ .gap = 2, .grow = 1 }, .{
|
||||
ui.listItem(.{ .icon = "file-text", .selected = model.file == .main, .on_press = .select_main, .semantics = .{ .role = .treeitem } }, "main.zig"),
|
||||
ui.listItem(.{ .icon = "file-text", .selected = model.file == .view, .on_press = .select_view, .semantics = .{ .role = .treeitem } }, "view.zig"),
|
||||
}),
|
||||
}) else ui.stack(.{}, .{}),
|
||||
ui.listItem(.{
|
||||
.icon = "folder",
|
||||
.expanded = model.assets_open,
|
||||
.on_toggle = .toggle_assets,
|
||||
.on_press = .select_assets,
|
||||
.semantics = .{ .role = .treeitem },
|
||||
}, "assets"),
|
||||
})
|
||||
```
|
||||
|
||||
## Attributes
|
||||
|
||||
<AttrTable attrs={["role", "expanded", "selected", "on-toggle", "on-press", "gap", "label"]} />
|
||||
@@ -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](/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);
|
||||
@@ -103,15 +103,15 @@ pub fn main(init: std.process.Init) !void {
|
||||
}
|
||||
```
|
||||
|
||||
`create` requires every `Model` field to carry a default; the model starts as `.{}` and boot state is assigned through the returned pointer. The `scene` declares the native window and its GPU surface view — see [Windows](/windows) and [Native Surfaces](/native-surfaces) for multi-view scenes.
|
||||
`create` requires every `Model` field to carry a default; the model starts as `.{}` and boot state is assigned through the returned pointer. The `scene` declares the native window and its GPU surface view — see [Windows](/docs/windows) and [Native Surfaces](/docs/native-surfaces) for multi-view scenes.
|
||||
|
||||
## Rebuilds and widget identity
|
||||
|
||||
After every `update`, the runtime rebuilds the view from the model. Rebuilds are cheap and safe by design:
|
||||
|
||||
- **Widget identity is structural.** A widget keeps its id across rebuilds, reorders, and hot reloads, so engine-owned state — scroll offsets, text carets, focus — survives. List items carry `key` (or `global-key` for items that move between containers) to keep identity through reorders. Unkeyed same-kind siblings take positional identity (sibling index), so an `<if>` that inserts or removes an earlier same-kind sibling re-disambiguates the trailing ones — engine-owned state like carets and scroll can hop; keyed items and keyed ancestors hold identity.
|
||||
- **The source wins.** Engine-retained state (a scroll offset, a toggle) survives rebuilds until the model asserts a different value; then the model's value applies. This is why controlled patterns echo runtime-applied values back through the model — see [State & Data Flow](/state).
|
||||
- **Errors degrade, they never crash.** A failing `update` arm is caught, recorded in a bounded error ring (visible in [automation](/automation) snapshots as `dispatch_errors=`), and the app keeps running.
|
||||
- **The source wins.** Engine-retained state (a scroll offset, a toggle) survives rebuilds until the model asserts a different value; then the model's value applies. This is why controlled patterns echo runtime-applied values back through the model — see [State & Data Flow](/docs/state).
|
||||
- **Errors degrade, they never crash.** A failing `update` arm is caught, recorded in a bounded error ring (visible in [automation](/docs/automation) snapshots as `dispatch_errors=`), and the app keeps running.
|
||||
|
||||
## Hot reload in development
|
||||
|
||||
@@ -156,8 +156,8 @@ 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](/typescript#effects-are-cmd-data). In a Zig core, declare `.update_fx` instead of `.update` and spawn from message arms; boot-time work goes in `.init_fx`, which runs exactly once before the first paint. See [Native UI: Effects](/native-ui#effects).
|
||||
`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
|
||||
|
||||
`UiApp` is a layer over the lower-level `App`/`Runtime` pair, which any app can use directly — for custom lifecycle callbacks, imperative window and view management, or embedding [web content](/frontend). The [App & Runtime](/runtime) reference documents that layer, and [Embedded App](/embed) covers driving the runtime from an existing host (including iOS and Android).
|
||||
`UiApp` is a layer over the lower-level `App`/`Runtime` pair, which any app can use directly — for custom lifecycle callbacks, imperative window and view management, or embedding [web content](/docs/frontend). The [App & Runtime](/docs/runtime) reference documents that layer, and [Embedded App](/docs/embed) covers driving the runtime from an existing host (including iOS and Android).
|
||||
@@ -24,8 +24,6 @@ The manifest `native init` generates — identity, one shell window with a GPU s
|
||||
.title = "My App",
|
||||
.width = 480,
|
||||
.height = 320,
|
||||
.restore_state = false,
|
||||
.restore_policy = "center_on_primary",
|
||||
.views = .{
|
||||
.{ .label = "main-canvas", .kind = "gpu_surface", .fill = true, .role = "Counter canvas", .accessibility_label = "Counter", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
|
||||
},
|
||||
@@ -45,7 +43,7 @@ The manifest `native init` generates — identity, one shell window with a GPU s
|
||||
|
||||
## Example: app with web content, menus, and shortcuts
|
||||
|
||||
A fuller manifest for an app that also [embeds web content](/frontend) and declares commands, shortcuts, menus, and packaging metadata:
|
||||
A fuller manifest for an app that also [embeds web content](/docs/frontend) and declares commands, shortcuts, menus, and packaging metadata:
|
||||
|
||||
```zig:app.zon
|
||||
.{
|
||||
@@ -148,25 +146,41 @@ A fuller manifest for an app that also [embeds web content](/frontend) and decla
|
||||
<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="/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="/security">Security</a>)</td>
|
||||
<td>Feature declarations (see <a href="/docs/security">Security</a>). <code>"store"</code> links the engine-owned record store; <code>"sqlite"</code> links relational SQL effects. They share one capability-shed SQLite object but use separate databases; see <a href="/docs/record-store">Record Store</a> and <a href="/docs/sqlite">Relational SQLite</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>dock_visible</code></td>
|
||||
<td>Initial macOS Dock and app-switcher presence. Defaults to <code>true</code>. Set <code>false</code> for an Accessory/menu-bar app; the policy is applied before startup windows are created, so no Dock tile flashes. Accessory apps must declare the <code>"tray"</code> capability. Runtime <code>Cmd.setDockPresence</code> can still promote or demote the app later.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>persist</code></td>
|
||||
<td>Engine-owned Model snapshot config: monotonic <code>version</code>, optional <code>debounce_ms</code>, and the <code>restore</code> Msg routes (<code>ok</code>/<code>none</code>/<code>err</code>). Requires <code>"persist"</code> in capabilities — see <a href="/docs/persistence">Model Persistence</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>images</code></td>
|
||||
<td>Optional registered-image budget: <code>.images = .{ .max_image_pixel_bytes = 8_388_608 }</code>. The default is 1 MiB and accepted values are 1–8 MiB. Encoded photos decode aspect-preservingly to fit; storage is lazy per used slot, but 16 fully used 8 MiB slots are a declared 128 MiB high-water. See <a href="/docs/dynamic-images">Dynamic Images</a>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>bridge</code></td>
|
||||
<td>Bridge command policies (see <a href="/bridge">Bridge</a>)</td>
|
||||
<td>Bridge command policies (see <a href="/docs/bridge">Bridge</a>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>security</code></td>
|
||||
<td>Navigation and external link policies (see <a href="/security">Security</a>)</td>
|
||||
<td>Navigation and external link policies (see <a href="/docs/security">Security</a>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>web_engine</code></td>
|
||||
<td><code>system</code> or <code>chromium</code>; Chromium is currently supported for macOS builds (see <a href="/web-engines">Web Engines</a>)</td>
|
||||
<td><code>system</code> or <code>chromium</code>; Chromium is currently supported for macOS builds (see <a href="/docs/web-engines">Web Engines</a>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>webview_layer</code></td>
|
||||
@@ -174,7 +188,7 @@ A fuller manifest for an app that also [embeds web content](/frontend) and decla
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>theme</code></td>
|
||||
<td>Built-in theme pack: <code>house</code> (default) or <code>geist</code>; an unknown name is a build/check error (see <a href="/theming">Theming</a>)</td>
|
||||
<td>Built-in theme pack: <code>house</code> (default) or <code>geist</code>; an unknown name is a build/check error (see <a href="/docs/theming">Theming</a>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>theme_accent</code></td>
|
||||
@@ -190,7 +204,7 @@ A fuller manifest for an app that also [embeds web content](/frontend) and decla
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>windows</code></td>
|
||||
<td>Window definitions (see <a href="/windows">Windows</a>)</td>
|
||||
<td>Window definitions (see <a href="/docs/windows">Windows</a>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>shell</code></td>
|
||||
@@ -202,11 +216,11 @@ A fuller manifest for an app that also [embeds web content](/frontend) and decla
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>shortcuts</code></td>
|
||||
<td>Keyboard shortcuts delivered as <code>shortcut</code> events (see <a href="/keyboard-shortcuts">Keyboard Shortcuts</a>)</td>
|
||||
<td>Keyboard shortcuts delivered as <code>shortcut</code> events (see <a href="/docs/keyboard-shortcuts">Keyboard Shortcuts</a>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>menus</code></td>
|
||||
<td>Native menu declarations delivered through the command event path (see <a href="/menus">Menus</a>)</td>
|
||||
<td>Native menu declarations delivered through the command event path (see <a href="/docs/menus">Menus</a>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>file_associations</code></td>
|
||||
@@ -218,7 +232,7 @@ A fuller manifest for an app that also [embeds web content](/frontend) and decla
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>frontend</code></td>
|
||||
<td>Frontend build/dev config (see <a href="/frontend">Frontend Projects</a>)</td>
|
||||
<td>Frontend build/dev config (see <a href="/docs/frontend">Frontend Projects</a>)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -244,7 +258,7 @@ Tooling parses and validates the schema. Runtime code can return the same shape
|
||||
|
||||
When an app uses both `windows` and `shell.windows`, labels must stay unique across both lists. Use `windows` for the simple compatibility path or `shell.windows` for native-first structure; do not define two window entries with the same label.
|
||||
|
||||
For a scene-first app — a `UiApp` passing its Zig scene (`shell_scene`) to the runner — the scene is authoritative at runtime: it re-applies size, title, and views when it loads. `app.zon`'s `.shell.windows[0]` exists because the host creates the startup window before the scene loads, and create-time-only properties must come from the manifest: `titlebar` chrome, `min_width`/`min_height` floors, and the show mode (canvas-first windows are created hidden and shown after the first frame presents). The numbers appearing in both places is by design — edit the scene for anything that can change after create (size, title, views), and the manifest for create-time chrome and floors.
|
||||
For a scene-first app — a `UiApp` passing its Zig scene (`shell_scene`) to the runner — the scene is authoritative at runtime: it re-applies size, title, and views when it loads. `app.zon`'s `.shell.windows[0]` exists because the host creates the startup window before the scene loads, and create-time-only properties must come from the manifest: `titlebar` chrome, `min_width`/`min_height` floors, overlay presentation (`transparent`, `always_on_top`, `click_through`, and `activate_on_show`), and the show mode (canvas-first windows are created hidden and shown after the first frame presents). The numbers appearing in both places is by design — edit the scene for anything that can change after create (size, title, views), and the manifest for create-time presentation and floors.
|
||||
|
||||
```zig
|
||||
.shell = .{
|
||||
@@ -269,11 +283,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="/native-ui">Native UI</a>). Platforms without the concept keep standard chrome. The same `titlebar` field is accepted on top-level `windows` entries.
|
||||
Each window takes a `label` plus optional `title`, `width`, `height`, `x`, `y`, `resizable`, `restore_state`, `restore_policy` (`clamp_to_visible_screen` or `center_on_primary`), `initially_hidden` (default false; create the native window ordered out until an explicit show/focus), `allows_fullscreen` (default true; false disables native fullscreen on macOS without disabling ordinary resizing), `min_width`/`min_height` (a content min-size floor the window itself enforces — macOS `contentMinSize`; the first shell window's declaration threads through the startup create like `titlebar`, negative values are a manifest error, 0 means no floor), and `titlebar` (`standard`, `hidden_inset`, `hidden_inset_tall` — the tall variant centers macOS's traffic lights in the 52pt unified band for toolbar-height headers — or `chromeless`, the fully-skinned opt-in that removes all OS chrome including the system buttons; only for apps that draw their own working window controls, see `examples/deck`). `restore_state` defaults to true and controls only whether the state store is consulted: a store hit restores the saved frame, an authored `x` or `y` is explicit placement, and a fresh window with neither is default placement. On macOS the default `clamp_to_visible_screen` policy keeps restored and explicit frames on their matching or nearest display, centers the main fresh window, and cascades fresh secondary windows; `center_on_primary` centers restored and fresh default frames on the primary display. `titlebar = "hidden_inset"` hides the titlebar and extends content under it (macOS keeps the traffic lights) — the first shell window's declaration threads through the STARTUP window create, so the main window's chrome is right from the first frame; the app's own header then takes over dragging and inset padding through the `window-drag` attribute and the `on_chrome` hook (see <a href="/docs/native-ui">Native UI</a>). Platforms without the concept keep standard chrome. The same fields are accepted on top-level `windows` entries. `dock_visible` is top-level app policy, not a window field: setting it false removes the Dock/app-switcher presence but does not hide a window; pair it with `initially_hidden = true` when the app should launch behind its status item.
|
||||
|
||||
Overlay presentation is also fixed at create time: `transparent` makes the top-level window alpha-capable, `always_on_top` selects its floating/topmost level, `click_through` passes pointer input to windows underneath, `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.
|
||||
|
||||
@@ -293,11 +311,11 @@ The optional `commands` list declares shared command metadata. The runtime still
|
||||
|
||||
An app can define up to 256 commands. Command ids can be up to 128 bytes and titles can be up to 128 bytes.
|
||||
|
||||
Generated runners load manifest commands into `RuntimeOptions.commands`. Native code can read the active catalog with `runtime.listCommands(...)`, and trusted WebView code can read it with `window.zero.commands.list()` when the built-in command bridge allows it. Use the catalog to keep menus, shortcuts, toolbar controls, tray items, and bridge callers aligned with the same command ids.
|
||||
Generated zero-config TypeScript and Zig-core runners load manifest commands into `RuntimeOptions.commands`; ejected runners use the same fallback. Native code can read the active catalog with `runtime.listCommands(...)`, and trusted WebView code can read it with `window.zero.commands.list()` when the built-in command bridge allows it. Use the catalog to keep menus, shortcuts, toolbar controls, tray items, and bridge callers aligned with the same command ids.
|
||||
|
||||
## `shortcuts`
|
||||
|
||||
The optional `shortcuts` list defines app-level keyboard shortcuts. Generated runners load these automatically:
|
||||
The optional `shortcuts` list defines app-level keyboard shortcuts. Generated zero-config TypeScript and Zig-core runners load these automatically, as do ejected runners:
|
||||
|
||||
```zig
|
||||
.shortcuts = .{
|
||||
@@ -316,7 +334,7 @@ Chromium builds are currently macOS-only; use the Linux system WebView backend w
|
||||
|
||||
## `menus`
|
||||
|
||||
The optional `menus` list defines native app menus. Generated runners load these automatically:
|
||||
The optional `menus` list defines native app menus. Generated zero-config TypeScript and Zig-core runners load these automatically, as do ejected runners:
|
||||
|
||||
```zig
|
||||
.menus = .{
|
||||
@@ -36,7 +36,7 @@ When the runtime publishes a snapshot, it writes these files to the automation d
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>snapshot.txt</code></td>
|
||||
<td>Runtime state: source kind, window metadata, native/WebView metadata including role, accessibility label, text, and focus state, <code>ready=true/false</code>, and <code>markup_watch=armed|off</code> in the header — whether the markup hot-reload watch is armed (only in builds where the app wired <code>.markup</code> with a <code>watch_path</code> and <code>io</code>, or registered compiled fragments through <code>fragment_watch</code> — i.e. Debug dev builds)</td>
|
||||
<td>Runtime state: source kind, window metadata, native/WebView metadata including role, accessibility label, text, and focus state, the configured command and app-menu catalogs, <code>ready=true/false</code>, and <code>markup_watch=armed|off</code> in the header — whether the markup hot-reload watch is armed (only in builds where the app wired <code>.markup</code> with a <code>watch_path</code> and <code>io</code>, or registered compiled fragments through <code>fragment_watch</code> — i.e. Debug dev builds)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>accessibility.txt</code></td>
|
||||
@@ -130,20 +130,24 @@ The runtime watches the command queue and processes these actions:
|
||||
<td>Dispatch pointer down/drag/up across a retained canvas widget</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>widget-wheel <view-label> <widget-id> <delta-y></code></td>
|
||||
<td>Dispatch wheel input at a retained canvas widget</td>
|
||||
<td><code>widget-wheel <view-label> <widget-id> <delta-y> [<delta-x>]</code></td>
|
||||
<td>Dispatch wheel input at a retained canvas widget (the optional <code>delta-x</code> scrolls the horizontal axis)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>widget-key <view-label> <key> [<text>]</code></td>
|
||||
<td>Dispatch key input to the focused retained canvas widget</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>widget-pinch <view-label> <scale> [<x> <y>]</code></td>
|
||||
<td>Dispatch a trackpad pinch gesture at a gpu-surface view: the real <code>pinch_begin</code>/<code>pinch_change</code>/<code>pinch_end</code> events, with one change carrying <code>scale - 1</code>. <code>scale</code> is the final multiplicative zoom for the gesture (1.5 zooms in 50%, 0.5 zooms out to half) — the cumulative product of <code>1 + delta</code> lands exactly on it. The anchor point defaults to the view center</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>shortcut <id></code></td>
|
||||
<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>
|
||||
@@ -257,6 +261,12 @@ Edits refuse rather than guess: a widget authored in Zig, a file that changed on
|
||||
|
||||
`zig build test-writeback-smoke` (macOS) drives the whole loop against the kanban example: query provenance, flip the button label through the verb, assert the repaint, verify the byte-exact diff, and flip it back.
|
||||
|
||||
## Session recording and blob growth
|
||||
|
||||
`NATIVE_SDK_SESSION_RECORD=/path/session.journal` records platform events and effect results; `NATIVE_SDK_SESSION_REPLAY` replays that journal without touching the original network, process, database, or file. Large effect payloads—including every streamed file-read chunk—live content-addressed under the sibling `blobs/` directory, and the journal stores each hash and length. Keep the journal and `blobs/` together when copying a recording.
|
||||
|
||||
The blob store deduplicates identical chunks but is intentionally unbounded today: there is no automatic size quota or garbage collector. Long recordings and repeated large imports can therefore grow the directory substantially. Treat a recording as one disposable artifact, monitor its directory size in long automation runs, and delete the journal plus its sibling `blobs/` directory when it is no longer needed. A future GC must reason over every retained journal before removing an unreferenced content address; Tier 5 does not guess at that retention policy.
|
||||
|
||||
## Custom directory
|
||||
|
||||
Pass a custom path to `automation.Server.init()`:
|
||||
@@ -49,7 +49,7 @@ Native controls can also bind a `command` when created with `runtime.createView(
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Platform support queries are available through `window.zero.platform.supports(...)` when `js_window_api` is `true`. The command accepts feature names from `PlatformFeature`, including `main_webview`, `child_webviews`, `native_views`, `native_control_commands`, `menus`, `tray`, `shortcuts`, `dialogs`, `clipboard_text`, `clipboard_rich_data`, `open_url`, `reveal_path`, `notifications`, `recent_documents`, `credentials`, `file_drops`, `app_activation_events`, and `gpu_surfaces`. JavaScript callers can also use camelCase aliases such as `mainWebView`, `nativeControlCommands`, `clipboardRichData`, `recentDocuments`, `fileDrops`, `appActivationEvents`, and `gpuSurfaces`. The helper accepts either a string or a selector object with `feature` or `name`; raw bridge payloads may use the same fields. Use an explicit `builtin_bridge` policy when you want per-command origin lists.
|
||||
Platform support queries are available through `window.zero.platform.supports(...)` when `js_window_api` is `true`. The command accepts every feature name from `PlatformFeature`: `main_webview`, `child_webviews`, `native_views`, `native_control_commands`, `menus`, `tray`, `shortcuts`, `dialogs`, `clipboard_text`, `clipboard_rich_data`, `open_url`, `reveal_path`, `notifications`, `recent_documents`, `credentials`, `file_drops`, `app_activation_events`, `gpu_surfaces`, `gpu_surface_scroll_drivers`, `context_menus`, `view_surface_adoption`, `audio_playback`, `audio_streaming`, `audio_spectrum`, `microphone_capture`, `system_audio_capture`, and `window_hide_on_close`. JavaScript callers can also use the camelCase aliases — `mainWebView`, `childWebViews`, `nativeViews`, `nativeControlCommands`, `clipboardText`, `clipboardRichData`, `openUrl`, `revealPath`, `recentDocuments`, `fileDrops`, `appActivationEvents`, `gpuSurfaces`, `gpuSurfaceScrollDrivers`, `contextMenus`, `viewSurfaceAdoption`, `audioPlayback`, `audioStreaming`, `audioSpectrum`, `microphoneCapture`, `systemAudioCapture`, and `windowHideOnClose`. The helper accepts either a string or a selector object with `feature` or `name`; raw bridge payloads may use the same fields. Use an explicit `builtin_bridge` policy when you want per-command origin lists.
|
||||
|
||||
## Window commands
|
||||
|
||||
@@ -246,7 +246,7 @@ Dialog commands are **always default-deny** and require an explicit `builtin_bri
|
||||
<tr>
|
||||
<td><code>native-sdk.os.showNotification</code></td>
|
||||
<td><code>notifications</code></td>
|
||||
<td>Show a native system notification with a title, optional subtitle, and optional body</td>
|
||||
<td>Show or replace a native notification; optional paired <code>actionLabel</code> / <code>actionCommand</code> fields dispatch an app command</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>native-sdk.os.revealPath</code></td>
|
||||
@@ -266,7 +266,7 @@ Dialog commands are **always default-deny** and require an explicit `builtin_bri
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
OS commands are **always default-deny** and require an explicit `builtin_bridge` policy. `native-sdk.os.openUrl` also checks `security.navigation.external_links`; the URL must match the external link allowlist before the platform service is called. macOS, Linux, and Windows system WebView hosts implement `openUrl`, `revealPath`, notifications, and recent-document commands; macOS Chromium also implements the current OS command set. Other platform hosts return `invalid_request` with the standard unsupported-service message until implemented.
|
||||
OS commands are **always default-deny** and require an explicit `builtin_bridge` policy. `native-sdk.os.openUrl` also checks `security.navigation.external_links`; the URL must match the external link allowlist before the platform service is called. An actionable notification additionally requires an allowed `native-sdk.command.invoke` policy and the `command` permission for the same origin, because activating it enters the normal app-command path. macOS, Linux, and Windows system WebView hosts implement `openUrl`, `revealPath`, notifications, and recent-document commands; macOS Chromium also implements the current OS command set. Other platform hosts return `invalid_request` with the standard unsupported-service message until implemented.
|
||||
|
||||
## Credential Commands
|
||||
|
||||
@@ -405,6 +405,8 @@ const app_permissions = [_][]const u8{
|
||||
|
||||
## JavaScript usage
|
||||
|
||||
Window creation accepts `titlebar` (`standard`, `hidden_inset`, `hidden_inset_tall`, or `chromeless`) plus the overlay fields `transparent`, `alwaysOnTop`, `clickThrough`, and `activateOnShow`. A transparent create without `url` stays source-less instead of inheriting the app WebView; transparent Windows windows also require `titlebar: "chromeless"` and cannot host WebView content.
|
||||
|
||||
```javascript
|
||||
await window.zero.windows.create({
|
||||
label: "tools",
|
||||
@@ -464,9 +466,12 @@ const result = await window.zero.invoke("native-sdk.dialog.showMessage", {
|
||||
|
||||
await window.zero.os.openUrl("https://example.com/docs/start");
|
||||
await window.zero.os.showNotification({
|
||||
id: "build-status",
|
||||
title: "Build finished",
|
||||
subtitle: "native-sdk",
|
||||
body: "All checks passed.",
|
||||
actionLabel: "Open results",
|
||||
actionCommand: "build.open-results",
|
||||
});
|
||||
await window.zero.os.revealPath("/Users/me/Downloads/report.pdf");
|
||||
await window.zero.os.addRecentDocument("/Users/me/Downloads/report.pdf");
|
||||
@@ -495,4 +500,4 @@ await window.zero.credentials.delete({
|
||||
});
|
||||
```
|
||||
|
||||
See also: [Multiple WebViews](/webviews) for frame and layer semantics, [Dialogs](/dialogs) for the full dialog type reference, [Capabilities](/capabilities) for OS capability support, and [Security](/security) for policy details.
|
||||
See also: [Multiple WebViews](/docs/webviews) for frame and layer semantics, [Dialogs](/docs/dialogs) for the full dialog type reference, [Capabilities](/docs/capabilities) for OS capability support, and [Security](/docs/security) for policy details.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bridge
|
||||
|
||||
For apps that [embed web content](/frontend), the bridge connects JavaScript in the WebView to native Zig handlers via JSON messages. Native-rendered apps have no bridge — markup dispatches typed messages straight into `update` (see [App Model](/app-model)).
|
||||
For apps that [embed web content](/docs/frontend), the bridge connects JavaScript in the WebView to native Zig handlers via JSON messages. Native-rendered apps have no bridge — markup dispatches typed messages straight into `update` (see [App Model](/docs/app-model)).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -175,4 +175,4 @@ try {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
See also: [Builtin Commands](/bridge/builtin-commands) for `native-sdk.command.*`, `native-sdk.window.*`, `native-sdk.view.*`, `native-sdk.webview.*`, `native-sdk.dialog.*`, `native-sdk.os.*`, `native-sdk.clipboard.*`, and `native-sdk.credentials.*`.
|
||||
See also: [Builtin Commands](/docs/bridge/builtin-commands) for `native-sdk.command.*`, `native-sdk.window.*`, `native-sdk.view.*`, `native-sdk.webview.*`, `native-sdk.dialog.*`, `native-sdk.os.*`, `native-sdk.clipboard.*`, and `native-sdk.credentials.*`.
|
||||