fenestra 0.40: author in JSON, preview live, agents watch motion

Released 2026-07-10

fenestra is a pure-Rust native GUI framework — winit windowing, wgpu GPU access, vello vector rendering, parley text shaping, taffy layout, no browser or webview anywhere in the stack — built around a second commitment alongside the usual "looks good": rendering is a deterministic pure function of (element tree, theme, size, scale), so both a human and an AI coding agent can render, inspect, and assert against a UI with no display attached. Try the live demo (WebGPU, no DOM, no CSS — the same vello-on-wgpu code path as the native window) or read the book for the guided tour.

0.40 lands four things aimed squarely at that second commitment: the JSON authoring grammar now covers the whole widget kit, a live-reload preview window for authoring it, a way for an agent to watch motion instead of only inspecting a still frame, and a new color picker widget. Every change is additive — every prior golden render is byte-identical.

The fenestra/1 grammar now reaches the whole kit

fenestra has shipped a JSON description format (fenestra/1) since 0.29: author a UI tree as strict, schema-validated JSON, parse it to the same Element the Rust builders produce, then verify it headlessly — query the access tree, check accessibility, match against an aria snapshot, diff a screenshot. 0.40 closes the coverage gap that made that loop incomplete: 14 widgets that were previously code-only become authorable — field, split_pane, combobox, multi_select, tag_input, date_picker, tree, toast, data_table, virtual_list, popover, dropdown_menu, command_palette, color_picker — plus a new image node.

image decodes a base64 PNG through a dependency-free RFC 4648 decoder (no new dependency for something this small), and — because an agent is exactly the kind of consumer that will hand a framework a hostile payload — the PNG's declared dimensions are checked against a limit before any pixel buffer is allocated, closing the classic decompression-bomb shape (a tiny payload with an enormous declared width/height). Alt text is a required field, not a default, because an unlabeled image is precisely the failure the accessibility gate exists to catch.

The three widget capabilities that need a computed, per-event payload instead of an inert intent string — data_table column resize/reorder, combobox/command-palette keyboard-cursor state, and date_picker's calendar-grid keyboard navigation — are deliberately left out of the JSON grammar, and say so in their own node documentation, rather than being half-authorable and silently wrong.

fenestra preview <file>: a live-reload window for authoring JSON

fenestra preview my_ui.json

opens a real window rendering that description, polls the file every 200ms, and re-renders on save — an edit/save/look loop for JSON the same way cargo watch gives you one for Rust. A parse error never blanks the window or crashes the process: the last description that loaded cleanly keeps rendering underneath a themed, path-pointed error panel (so you keep your place while you fix a typo), and a broken very first load shows the panel alone. Runtime state carries across a reload on a best-effort basis — a binding the new edit still declares keeps its value, a new one seeds, a removed one drops.

Harness::film + fenestra film + film_ui: agents can watch motion play

Every fenestra tool up to now gave an agent a single still frame — accurate, but a transition is a sequence, and "did the drawer actually slide in, or did it just snap to its end state" was not a question the verification loop could answer. Harness::film(frames, interval_ms) captures a render sequence across the test harness's deterministic clock (call set_reduced_motion(false) first — determinism here comes from controlling the clock, not from suppressing animation), testing::assert_filmstrip_snapshot locks a captioned strip as a golden through the same snapshot machinery as a static render, and the public, non-panicking testing::filmstrip_image composes a strip outside of tests.

Both the CLI and the MCP surface drive any interaction steps first, with motion already turned on, so a step-triggered transition is still in flight when capture starts — the ordering that makes "click, then watch it animate" actually observable:

fenestra film my_ui.json --steps open_drawer.json \
  --frames 12 --interval-ms 80 --out drawer.png

Both report the actual frame count, interval, and scale used after clamping (MAX_FILM_FRAMES 64, MAX_FILM_INTERVAL_MS 60s) — never just the request — so a hostile or mistaken input degrades to a smaller strip instead of hanging or lying about what it captured. This brings the Model Context Protocol server to thirteen tools: render_ui, query_ui, interact, check_a11y, focus_order, check_layout, match_aria_snapshot, match_screenshot, describe_vocabulary, describe_schema, validate, run_scenario, and the new film_ui.

OKLCH color picker (fenestra_kit::color_picker)

A generated lightness×chroma pad, hue and checkerboard-backed alpha strips, a swatch, and a forgiving hex/oklch() text entry — built only on core's existing oklch()/oklch_of(), so the widget has no invalid state to represent: every construction path runs through the same gamut-mapping core already uses, which reduces chroma to stay in-gamut rather than producing an unrepresentable color. When that happens, the picker shows an honest amber indicator reading "At the sRGB gamut edge…" — not the common "showing the nearest displayable color" framing, which implies a substitution happened when nothing was actually swapped out.

Every channel is keyboard-operable, including the 2D pad, which exposes independent Lightness/Chroma focus targets over the one draggable field (the same two-thumb precedent the range slider already established). A proptest sweep over the full f32 domain — not just the values the widget's own call sites happen to produce — caught a rem_euclid hue-wrap boundary bug (a value a hair below zero wrapping to exactly 360.0 instead of 0.0) before it shipped.

A P0 closed by a second, adversarial review

After this batch's initial review passed, a follow-up adversarial pass (explicitly hunting for what the first review would have missed under scale or hostile input) found that virtual_list's per-row render closure reset the aggregate image-decode budget on every row instead of sharing one budget across the frame — so a virtual_list of many large images, with a small enough row height to collapse the virtualization window onto every row at once, could decode the entire list simultaneously and defeat the very memory cap this release introduced. It's fixed with a per-frame budget (fenestra_core::frame_epoch) that all rows draw from; over-budget rows now fail decode before allocation and degrade to spacers instead of allocating. Two related P1s — images re-decoding on every rebuild instead of being cached, and a drag-step target selector that panicked on a miss instead of returning a structured error — were fixed alongside it, each with a regression test written to fail first.

The reason to mention this in a launch note rather than bury it in a changelog: every one of these bugs was reachable from plain fenestra/1 JSON through the same MCP surface an agent drives, which is the exact threat model fenestra's agent-facing surface has to hold up under. Treating that surface as adversarial — including a second pass specifically looking for what the first pass missed — is part of the same trust story as the verification loop itself, not a separate concern from it.

Try it

cargo add fenestra

or start from the template:

cargo generate richer-richard/fenestra-template

which includes a headless UI test and CI out of the box. To see the render → verify loop this release deepens without installing anything, open the live demo — the same vello/wgpu code path compiled to WebAssembly, running in your browser over WebGPU — or read AGENTS.md for the build → render → look → verify workflow an AI coding agent follows against a fenestra app. The full changelog for every release since 0.29 is in CHANGELOG.md.