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 —loadedwith 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_statuswith 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—idechoes the requested ImageId so concurrent loads sharing one arm stay distinguishable; the fifteen-memberImageStateunion 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, andTsUiApp'simage_cache_dirderiving 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; theimageattribute'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
.imageeffect-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 })withEffects.imageMsg(...)routing, a fake-executor seam (pendingImageLoad*,feedImageBytesrunning the REAL decode+register path,feedImageResult), andimageCachePathderiving the cache convention; the encoded source is bounded at 1.25 MiB from every source alike and over-bound sources fail whole withtoo_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), andfx.quitApp()quits gracefully through the same shutdown path a last-window close takes — both mirrored in the TypeScript tier asCmd.showWindow(label)andCmd.quitApp(). - Hidden state is honest, journaled window state:
WindowState.hiddenrides 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 checkerror, 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:
/fontsdocuments registering faces for scripts beyond bundled coverage — theOptions.fontsscaffold 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-fontsstep 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.allocatorat the slot's first registration (freed byRuntime.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 thefx.registerImage/fx.registerImageBytesbindings) now surfaceerror.OutOfMemorywhen 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/CompiledMarkupImportsno longer fail with "evaluation exceeded 1000 backwards branches" on.nativedocuments 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 devno longer crashes at startup: Debug builds on x86_64 Linux segfaulted creating the first shell view (a general protection fault in the GTK host'snative_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-smokejob scaffolds the default template, builds it-Doptimize=Debug(thenative devmode 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.registerCanvasFonton a live runtime) now rebuilds every installedUiAppsurface — 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.deinitnow 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.registerGpuSurfaceFontnow returns the host's ownership token for the registration (u64; 0 from hosts that retain no per-id state), and the new optionalunregisterGpuSurfaceFont(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 changeregister_gpu_surface_font_fnto return au64token (0 is fine for a stateless accept) and may supplyunregister_gpu_surface_font_fnto 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, andx === null ? { ...fallback } : xspellings — 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 tightif/orelseexpression forms. - Optional switch payloads keep their optional through the capture: reading a
number | nullpayload (directly or via aconstlocal) inside itscaseno longer mistypes the value as non-null, which routedmsg.parsed === null ? ... : ...around the narrowing lowering and emitted?f64intof64slots. - Early-exit guards narrow like early returns:
if (r === null) break;in a parse loop (and thecontinue, labeled, multi-statement,throw-exit, andif (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
breakthat exits aswitchfrom inside a clause body now teaches at transpile time — Zig'sbreakbinds 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;insidecase "got":no longer leaves anumber | nullpayload typed non-null for the rest of the clause (which emittedif (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: Quoteannotation) now types the local by the arm the condition narrows —Quote, exactly as tsc infers — instead of the raw optional, which declared a?Quotetemporary that failed Zig compilation at its first non-optional use (expected type 'Quote', found '?Quote'). - A redundant nested
switchon 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 narrowxafter 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
letbindings never fuse into aconst:let p = next(i); if (p === null) continue; p = { ...p, v: 10 };no longer fuses the declaration and guard intoconst p = next(i) orelse continue;(Zig:cannot assign to constant); the binding stays avar, 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 emittingp.? == 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 keepspnarrowed 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/continueguards in loops, throws caught by an enclosingtry— 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 loweredif (!(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, andbreak-carried kills still land only on the post-loop state. - Canvas-app hosts compile silently without a WebView SDK: the informational
#pragma messagein the GTK host's WebKitGTK stub path (and its Windows WebView2 twin) is gone — zig renders every clang diagnostic of a failing C compile aserror:, 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.