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.
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 JSONfenestra 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 playEvery 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.
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.
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.
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.