Introduction
fenestra is a pure-Rust native GUI framework: winit windowing, wgpu GPU access, vello vector rendering, parley text shaping, taffy flexbox/grid layout. No browser, no webview, no HTML/CSS parser — and two commitments that shape everything else:
- Web-grade aesthetics by default. OKLCH color ramps generated from accent hues, 4px-grid spacing, layered soft shadows, real typographic hierarchy, focus rings, 120-300ms easing. The widget kit looks like a polished web product out of the box, and an editorial design language (custom faces, free-form type, duotone fields) is a few calls away.
- You can see what you build — programmatically. Rendering is a pure
function of
(element tree, theme, size, scale), it runs without a display server, and it is deterministic. Humans get golden tests; AI agents get a build → render → look → verify loop (AGENTS.md is the agent manual).
Try the live demo — the same code compiled to WebAssembly, rendering through WebGPU.
Where things live
| Crate | Contents |
|---|---|
fenestra | facade: run(), prelude, examples |
fenestra-core | element IR, theme, layout, text, paint, input, accessibility |
fenestra-shell | windowed runner + headless renderer and test harness |
fenestra-kit | the themed widget kit |
Getting started
cargo add fenestra
use fenestra::prelude::*;
struct Counter { n: i64 }
#[derive(Clone)]
enum Msg { Inc, Dec }
impl App for Counter {
type Msg = Msg;
fn update(&mut self, msg: Msg) {
match msg { Msg::Inc => self.n += 1, Msg::Dec => self.n -= 1 }
}
fn view(&self) -> Element<Msg> {
col().p(SP6).gap(SP4).items_center().children([
text(self.n.to_string()).size(TextSize::Xl2).weight(Weight::Semibold),
row().gap(SP3).children([
button("Decrement").variant(ButtonVariant::Secondary).on_click(Msg::Dec),
button("Increment").on_click(Msg::Inc),
]),
])
}
}
fn main() { fenestra::run(Counter { n: 0 }, WindowOptions::titled("Counter")) }
cargo run, and you have a themed, antialiased, GPU-rendered window.
Linux needs libfontconfig1-dev pkg-config and a Vulkan driver. The same
crate compiles to wasm32-unknown-unknown and runs on WebGPU (see the
repository’s pages.yml for the exact bundling steps).
The examples directory is the tour: counter, dashboard (the flagship),
gallery, clock, toasts, web_demo, poster, bench.
Thinking in fenestra
fenestra is Elm-shaped, aggressively so.
- The app owns all state.
view(&self)builds the whole element tree from scratch on every redraw. There is no diffing and no retained widget objects; the tree is cheap plain data (a full real screen builds, lays out, and paints in ~0.3 ms — see BENCHMARKS.md). - Handlers carry messages, not closures over state.
button("Save").on_click(Msg::Save)—update(&mut self, msg)is the only place state changes. - Widgets never keep their own copies of your data. A
text_inputshows exactly&self.valueand emitson_input; if you do not store the new value, typing does nothing. This is the number-one beginner surprise and it is by design: one source of truth. - Identity is explicit where it matters. Retained UI state (scroll
offsets, editor carets, animations, open menus) keys off a stable
WidgetIdderived from tree position — or from.id("..."), which you should set on inputs, selects, scroll containers, and overlays. - Composition is
Element::map. A component written around its own message type embeds anywhere:
fn card_component() -> Element<CardMsg> { /* ... */ }
let el: Element<AppMsg> = card_component().map(AppMsg::Card);
Beautiful by construction
The web styles with CSS — a universal solvent that can express anything, including the ugly, the illegible, and the inconsistent. Beauty in CSS is discipline you supply. fenestra inverts that: the architecture makes the beautiful path the only easy path, and then proves the result. The constraints are the feature.
- Typed IR, not strings.
Styleis a typed struct (layout / paint / text); every property autocompletes and type-checks. Nostyle="…"to typo, no cascade or specificity to fight, no!important. An invalid style doesn’t compile. - You can’t name a raw color. Color comes only from theme tokens
(
t.accent,element_hover, …) — the API funnels you to the OKLCH ramps, never#bada55. Clashing ad-hoc colors are unreachable. (Data viz is the one recognized exception, and even it routes through the gamut-safeoklch.) - Generated, perceptually-uniform, gamut-safe. Palettes are generated in
OKLCH and gamut-mapped by reducing chroma, not lightness, so the ramp’s
rhythm survives. The 12-step semantic scale makes interaction arithmetic
(“+1 step”), not art;
Theme::derivecollapses the whole palette to three inputs. - Provably legible (APCA). Because color resolves at construction,
validate_contrastproves every text pair clears its floor — for every theme and Look, in tests. No CSS framework can guarantee that. - One interaction recipe. The state layer, motion tokens, focus ring, and control sizes are uniform tokens, not per-widget — consistency is structural.
- No cascade.
themedclosures defer color to the theme; one flip restyles everything coherently. - Beauty is testable. Headless golden PNGs lock the rendered output; every flagship is rendered and eyeballed against a pixel budget in CI. The aesthetic is enforced, not aspirational.
The thesis: a curated, generated, validated, golden-locked design system expressed as types. You reach for tokens because the tokens are the path of least resistance, and what they compose to has already been proven legible and locked to a reference render.
Influences
fenestra’s design steals deliberately, and credit is part of the contract:
- Elm / elm-ui — the architecture itself, and the proof that a small typed layout vocabulary replaces CSS.
- Testing Library & Playwright — query priority (role > label >
value > test-id), strict locators, aria snapshots, and
failure-artifact UX; fenestra’s
by::queries andaccess_yamlmirror them on purpose, so the muscle memory transfers. - Flutter — golden tests with self-explaining failure images, the
widget inspector’s source provenance (
debug_tree’ssrc=lines), and the hard-won lesson that cross-platform pixel exactness needs a named reference platform, not wishful thinking. - Jetpack Compose — one semantics tree serving accessibility and tests; Paparazzi’s record/verify screenshot ergonomics.
- SwiftUI — modifier chaining as the API shape worth copying.
- Qt & AppKit —
QUndoStack/NSUndoManagersemantics for editing (coalesced typing, event-turn boundaries), model/view for big data. - Avalonia.Headless — the stepwise headless harness with explicit clock control.
- egui — the embedding “narrow waist” that made it ubiquitous, and (with Dear ImGui’s test engine and iced’s message assertions) the Rust-native testing prior art fenestra builds past.
The builder vocabulary
Everything autocompletes; nothing is a macro.
Constructors — div(), row(), col(), stack() (z-stack),
text(s), spacer(), divider(), path(bez, viewbox, stroke),
image_rgba8(w, h, pixels), raw_input(value, placeholder),
raw_text_area(value, placeholder), rich_text([span("…") .weight(..).color(..).size_px(..).family(..).italic(), …]).
Children — .children([...]) for one type, .children((a, b, c))
(a tuple, up to 12) to mix kit builders and elements without wrapping,
.child(x) to append one.
Accessibility — .semantics(Semantics::Button), .label("…"),
.live() (polite announcements); inputs expose value + selection.
Queries (tests) — by::role(sem).name("…"), by::label,
by::value, by::id + _contains forms; frame.get/query/get_all,
frame.access_yaml(), frame.debug_tree().
Layout — padding .p .px .py .pt .pr .pb .pl, margins .m .mx ...,
.gap, sizes .w .h .min_w .max_w .min_h .max_h (f32 = logical px,
Length::Pct), .w_full() .h_full() .grow() .shrink0() .wrap(),
alignment .items_start/center/end/baseline(),
.justify_start/center/end/between(), position .absolute() +
.top/.right/.bottom/.left, grid .grid_cols/.grid_rows(tracks) +
.grid_col/.grid_row(start, span), .overflow_hidden(), .scroll_y(),
.stick_to_bottom().
Paint — .bg(color_or_gradient), .border(w, color), .rounded(r),
.rounded_full(), .shadow(ShadowToken::Sm), .opacity(v).
Text — .size(TextSize::Sm), .size_px(148.0), .weight(Weight::Semibold),
.color(c), .mono(), .family(FamilyRole::Display), .tracking(em),
.leading(multiple), .truncate(), .text_align(..).
Interaction — .on_click(msg), .on_right_click(msg),
.on_double_click(msg), .on_hover(msg), .on_key(f), .on_drag(f),
.on_input(f), .on_close(msg), .on_file_drop(f), .drag_source(s),
.on_drop(f), .on_type_ahead(f), .focusable(true), .autofocus(),
.selectable(), .enter(transition),
.disabled(b), .cursor(Cursor::Pointer); state variants
.hover/.active/.focus(|s| ...) and _themed versions;
.transition(Transition::colors()); .keyframes(..); .spin(ms).
Kit widgets — button checkbox switch radio slider text_input text_area select tooltip modal toast_stack tabs card stat_card badge avatar progress spinner table data_table callout virtual_list menu dropdown_menu context_menu popover combobox command_palette split_pane tree_view date_picker badge_dot progress_indeterminate icons::* icons::lucide::* (38 icons); charts live in fenestra-charts
(sparkline line_chart bar_chart), markdown in fenestra-markdown
(markdown(src).on_link(..)), and packaged design languages in
fenestra-looks (product editorial terminal).
Tokens — spacing SP0..SP16, radii R_SM..R_FULL, TextSize,
Weight, ShadowToken, MotionDuration, themes via
Theme::{light, dark, from_accent, duotone}.
Theming and design languages
A Theme is a bag of resolved color tokens generated from hues:
let theme = Theme::from_accent(160.0, Mode::Dark); // teal accent
from_accent produces two 12-step OKLCH ramps (tinted neutrals + accent),
status palettes (danger/warning/success), text roles, borders, surfaces,
and shadows. Gamut mapping reduces chroma, never clips, so every hue is
safe.
Widgets defer their colors with themed closures, because view() has no
theme parameter:
div().themed(|t: &Theme, s| s.bg(t.surface).border(1.0, t.border))
Flip Mode::Light/Mode::Dark (or return a different theme from
App::theme) and everything follows.
Web-grade by default: derive
When you don’t want to think in ramps at all, derive the whole palette from three inputs — Linear’s model, on fenestra’s OKLCH scales:
let theme = Theme::derive(
BaseField { hue: 80.0, chroma: 2.5 }, // warm paper field
40.0, // terracotta accent hue
Contrast::High, // crisp ink-on-paper
Mode::Light,
);
base is the neutral field (its hue and how far it departs from gray),
accent_hue the brand hue, and contrast (Low / Standard / High) scales
every step’s lightness distance from the page background. from_accent and
duotone are special cases — derive at Standard contrast reproduces them
exactly — and every contrast level still clears the APCA floors, so derivation
never ships an illegible theme. Recipes carry it too:
{"mode":"light","derive":{"base_hue":80,"base_chroma":2.5,"accent_hue":40,"contrast":"high"}}.
A matching corner-radius family comes from one knob. Theme::radius is a
RadiusScale the whole kit reads — buttons, inputs, selects, cards, menus,
modals, tooltips, even concentric menu items resolve their corners from it — so
theme.with_radius(scale) re-rounds (or un-rounds) everything at once.
RadiusScale::from_base(8.0) yields {sm, md, lg, xl} at fenestra’s ratios
(0.6 / 1.0 / 1.4 / 2.0 ×); the default base (10) reproduces R_SM…R_XL
exactly (the stock look is unchanged), RadiusScale::sharp() (1–4px) gives
crisp near-square tech chrome, and RadiusScale::soft() a rounder, friendlier
feel. Pills and avatars (R_FULL) stay round regardless.
The 12-step scale
Each ramp follows the Radix model, so styling is arithmetic rather than art. The neutral steps carry names:
| step | role | step | role |
|---|---|---|---|
1 bg | app background | 7 border_strong | strong border |
2 surface | subtle surface | 9 text_subtle | low-emphasis text |
3 element | element fill | 11 text_muted | secondary text |
4 element_hover | hovered fill | 12 text | primary text |
5 element_active | pressed fill | ||
6 border | border |
Interaction is “+1 step”: a control rests on element, hovers to
element_hover, presses to element_active. Solid accents do the same —
accent → accent_hover → accent_active (one OKLCH-lightness notch
darker), and each status color carries solid / solid_hover /
solid_active. Pressed colors are mode-invariant, so a button feels the
same in light and dark.
Every ramp also has a translucent alpha twin (neutral_alpha,
accent_alpha) — the same step rendered as the lowest-alpha color that
composites over bg back to the solid value. Use a twin where a tint must
read correctly over an arbitrary surface, not just the page background.
Provably legible: APCA
Because fenestra resolves every color at construction, it can prove a theme is readable — something no CSS framework can do:
let theme = Theme::from_accent(262.0, Mode::Dark);
assert!(theme.validate_contrast().is_ok()); // every text pair clears its floor
validate_contrast scores each text/background role pair with APCA
(apca::lc, the APCA-W3 0.98G-4g algorithm) against a role-tiered
lightness-contrast floor — primary text Lc 75 (the stock themes reach
90+), secondary text 55, control labels 60, colored component text 40 —
and returns the pairs that fall short. The built-in themes and every
shipped Look are asserted to pass in headless tests, and your own themes
can be too. (APCA scores text legibility, so borders and other non-text
contrast aren’t checked.)
The role floors are fixed per tier, but two helpers go finer. APCA is the
draft WCAG-3 contrast method, and apca::required_lc(size_px, weight) exposes
its readability criterion as a function: small or thin text needs more Lc,
large or heavy text less. Feed it to theme.contrast_ok(text, bg, size_px, weight) to prove a specific label legible at its real rendered size — not
just against a tier average. And theme.text_on(bg) returns the more-legible
ramp extreme — a theme-tinted neutral that reads strongly on any custom or
status surface (the on_accent rule generalized to colors the theme never
generated; on a hard mid-tone, where no color reads well, it lands at
secondary-text grade):
let theme = Theme::light();
// A 13px caption needs more contrast than 16px body text:
assert!(theme.contrast_ok(theme.text, theme.surface, 13.0, 400.0));
// Legible text for an arbitrary brand surface, picked from the ramp ends:
let label = theme.text_on(brand_color);
The role floors (75/60/55/40) are unchanged regression sentinels;
required_lc now anchors them to the same APCA scale (required_lc(16, 400)
is the primary-text floor, 75).
Elevation
Shadows are layered (a tight contact shadow under a soft ambient one) and
tinted with the surface hue at low chroma rather than flat black, so an
editorial green field casts a green-black shadow. ShadowToken runs
Xs/Sm/Md/Lg/Xl; the Xl token is a three-layer overlay shadow
for modals. In dark mode, elevation lightens surfaces
(elevated_surface(level)) rather than relying on shadows. Solid controls
can carry a 1px inset top highlight (.highlight_top(color)) — the subtle
top sheen that reads as “raised.”
Rather than re-typing radius + fill + border + shadow at each call site, a
Surface material bundles them per elevation role — Card, Raised,
Popover, Menu, Modal, Thumb, Tooltip — resolved against the theme.
el.surface(Surface::Menu) gives a floating panel its whole look in one call;
floating roles carry radius and shadow depth at or above the resting card by
construction, so every floating thing matches. The kit’s cards, menus, modals,
toasts, tooltips, and slider thumbs all derive from this one table.
For an edge that sits outside the box, .ring(width, color) draws a crisp
band hugging the corner radius (the “ring, not border” look) — rendered as a
zero-blur spread shadow, so unlike .border(..) (an edge stroke) it never
covers content and recolors with zero layout cost. Reach for it for selection
and focus rings and sub-pixel hairlines; it stacks and composes with shadows.
How a resting card separates is a knob too: Theme::elevation is Shadowed
(the stock subtle card shadow) or Flat — theme.with_elevation(Elevation::Flat)
drops the shadow on resting Card/Raised surfaces in favor of their border
and surface tone-step. That’s sharper, and the honest choice in dark mode where
shadows barely register; floating roles (menus, popovers, modals, tooltips)
always keep their shadow. The console Look ships Flat.
Borders resolve per edge, too: alongside the uniform .border(w, color),
.border_top/right/bottom/left(w, color) draw straight hairline edges — a
header’s bottom rule, a left accent rail, a ruled table row — with no manual
1px divider child.
Typography
Letter spacing follows Inter’s dynamic-metrics tracking curve at the
actual font size (positive at caption sizes, tightening as text grows), so
display sizes are tracked correctly without hand-tuning. Tabular figures
are one call — .tabular() — for tables, timers, and any numbers that
align in columns or update in place:
text(format!("{revenue:>10}")).tabular()
Interaction, motion, and sizing tokens
Interaction is tokenized too, so the whole kit moves and reacts in one
language (the Interactivity chapter shows the builders). The state layer
(STATE_LAYER) is the veil a control lays over itself — hover 8%, focus and
press 12%, drag 16%. Motion lives in MotionDuration (100–300 ms) and the
Material easing curves EASE_STANDARD / EASE_DECELERATE / EASE_ACCELERATE;
PRESS_SCALE (0.97) is the pressed-control dip. The focus ring is
FOCUS_RING — a 3px halo at 0.5 alpha flush outside a ring-colored border,
recolored to the danger hue when a control is .invalid(true).
Control sizes share a height grid so a row of mixed controls lines up:
| size | height | font | size | height | font |
|---|---|---|---|---|---|
Xs | 24px | Xs | Md | 36px | Sm |
Sm | 32px | Sm | Lg | 40px | Base |
ControlSize::metrics() resolves the full bundle — height, padding, gap, font,
icon edge — the kit’s buttons, inputs, and selects build from.
One Density knob packs that grid tighter or looser:
ControlSize::metrics_at(Density::Compact) shrinks height/padding/gap/icon for
dense pro-tool UIs, Spacious loosens them, and Comfortable (the default) is
the table above — byte-identical to before density existed. Density scales
spacing, not type: the label font stays tied to the ControlSize, so text
never shrinks below its legible size.
The ControlSize-driven widgets take it directly —
button("Save").density(Density::Compact), and likewise icon_button,
text_input, and select — so a form restyles by passing one value per
control (fenestra has no style inheritance, so density is explicit per widget,
not ambient). density_showcase renders all three side by side.
Optical adjustments
Some shapes have to measure “wrong” to look right, because the eye weighs the
area near a boundary. fenestra_core::optical carries the corrections:
CIRCLE_OVERSHOOT (~1.1284 — a circle must be ~12.84% larger than a square to
read as the same size; overshoot(size) applies it) and centroid(vertices),
the visual-mass center used to nudge an asymmetric shape — the play triangle
shifted right off its bounding-box center so it sits centered in its circle.
path() icons take these as builders: .optical_overshoot() scales a round or
pointed glyph up so it matches square-edged neighbors, and .optical_center()
seats an asymmetric glyph on its centroid (the play-button nudge). Both are
opt-in per icon — fenestra has no style inheritance and auto-detecting which
icons need correcting would silently shift every existing one — so an
uncorrected path renders byte-identically.
Beyond the SaaS look
Theme::duotone(neutral_hue, neutral_chroma, accent_hue, mode) builds
atmospheric fields — deep green, warm paper — instead of near-gray
neutrals. Custom faces register under font roles:
let mut fonts = Fonts::embedded();
fonts.register(FamilyRole::Display, playfair_bytes.to_vec());
text("Evolution").family(FamilyRole::Display).size_px(148.0);
The repository’s poster example reproduces an editorial study-guide
cover this way — golden-tested like everything else. The point: design
languages are code, and beauty is testable.
For the most expressive surfaces, fenestra_core::effects generates textures:
mesh(w, h, &[MeshPoint]) is a multi-point gradient field (the Stripe “liquid
light” look, blended in OKLab from theme-token colors), and grain(w, h, seed, intensity) is deterministic film grain to break up banding. Both return RGBA8
for image_rgba8 — generated, not shaded, so they golden-lock too.
Theme files
Themes serialize as recipes — the few numbers a theme generates from, not hundreds of resolved colors:
{"mode": "dark", "duotone": {"neutral_hue": 152.0, "chroma": 6.0, "accent_hue": 72.0}}
ThemeSpec::from_json(s)?.theme() resolves through the same builders
(Theme::dark, from_accent, duotone); spec.to_json() writes one.
Unknown fields are errors, so a typo’d recipe fails loudly instead of
silently falling back. Recipes stay tiny, hand-editable, and stable
across fenestra versions.
Looks
A Look is a complete design language — theme and typefaces bundled
into one value, applied in one call. The same app, six voices, from
the fenestra-looks crate — enumerate them with fenestra_looks::all(mode)
for a picker or gallery:
- product — the stock voice: Inter, neutral surfaces, blue accent.
- editorial — print energy: Playfair Display headlines over a deep duotone field (the poster’s language, packaged).
- terminal — instrument panel: JetBrains Mono everywhere, phosphor accent, built for dense tools.
- console — observability console: a cool-slate field under one
electric-lime accent, sans body with mono numerals, and sharp + flat
chrome (
RadiusScale::sharp()+Elevation::Flat) — the minimalist “tech” voice, distinct from terminal’s all-mono phosphor. - warm-editorial — warm paper and ink: a cream-and-terracotta field
derived (
Theme::derive) with Fraunces text-serif prose (a variable face with anopszoptical-sizing axis) under sans chrome, and Playfair Display kept for large display headlines — a proper display + text serif pairing. Set prose runs toFamilyRole::Serifand pair withOpticalSizing::Autoso the serif’s weight tracks the size. - playful — a soft pastel canvas with a saturated magenta accent, for whiteboard-class, friendly tools. (Ships with the base sans; a hand-drawn display face is a planned addition.)
let look = fenestra_looks::editorial(Mode::Dark);
let fonts = look.fonts(); // embedded base + the look's faces
render_element_with(view, &look.theme, size, &mut fonts);
// Windowed: WindowOptions::titled("…").with_font(role, bytes) per face.
Each look is golden-locked from the same sample screen, so its identity is pinned, not aspirational. Typefaces are vendored under their OFL licenses.
Two mechanics make looks possible and are available to your own:
registered faces win for every family role (register under
FamilyRole::Sans and body text changes voice), and a family must
cover the weights you request — asking for Semibold in a family that
only ships Medium falls back out of the family entirely, so looks
bundle 400–700.
Build your own look: a Theme (or ThemeSpec recipe) plus
(FamilyRole, bytes) pairs is the whole format.
Layout, scrolling, virtualization
Layout is taffy: real CSS flexbox and grid semantics.
row()/col()are flex containers;.grow(),.shrink0(),.gap(), alignment and justification work like the CSS you already know.div().grid_cols(vec![Track::Fr(1.0); 4])makes grids;.grid_col(start, span)places items..absolute()positions against the nearest relative ancestor;stack()overlays children in one cell, painting in order.- Text participates with real measurement (wrapping width in, wrapped
height out) and true first-line baselines for
items_baseline().
Scrolling
.scroll_y() clips and scrolls; wheel input routes to the deepest
scrollable that actually overflows. Scroll offsets persist per .id(..)
across rebuilds and clamp to the content range each frame. Scrollbars
fade in while scrolling and out after.
.stick_to_bottom() is the chat-log pattern: while the container sits
at its bottom edge, appended content keeps it pinned there; scrolling up
releases the pin; returning to the bottom re-pins.
Keyboard: PageUp/PageDown page the scroll container nearest the focused element by 90% of its viewport; Home/End jump to its ends. Both defer to the focused element first (text inputs keep Home/End for the caret).
In tests and headless runs, FrameState::scroll_to(id, offset) sets an
absolute offset (f32::MAX means “the bottom”).
Virtualization
For long lists, virtual_list(count, row_height, |i| row_element)
materializes only the visible window (plus overscan), with spacers
keeping scrollbar geometry exact. 100,000 rows cost ~0.09 ms per frame.
Rows are keyed by index, so their retained state stays put while the
window slides; handlers on rows dispatch normally. Constraints: fixed row
height, no overlays inside rows.
When row heights vary or are unknown,
virtual_list_variable(count, estimated_height, |i| row_element) places
rows from a prefix-sum height index seeded with your estimate; each
realized row feeds its measured height back, so offsets, the scrollbar,
and the total height self-correct as the user scrolls. Rows size
themselves — give each a real height (or content that has one). The
estimate only has to be in the right ballpark: it positions rows the
first time they appear, before measurement corrects them. See the
performance chapter for the convergence model.
Interactivity and transitions
Pointer: .on_click(msg) fires on press+release over the same element;
the pressed element captures the pointer until release. .on_drag(f)
maps captured pointer positions (as 0..1 fractions of the element rect)
to messages — sliders are built on it. .on_right_click(msg) fires on
right press (pair it with context_menu); .on_double_click(msg) fires
on two clicks within 0.4 s — both single clicks still fire first, so use
it for select-then-open patterns.
Keyboard: Tab/Shift-Tab cycle .focusable(true) elements in tree order;
Enter/Space activate the focused clickable; .on_key(f) sees key presses
while focused. Focus rings paint only for keyboard-driven focus — the
shadcn model: the control’s border swaps to the accent and a soft 3px halo
sits flush outside it. Mark a field .invalid(true) to recolor the ring to
the danger hue. .autofocus() focuses an element when it first appears —
dialogs and search fields, without any imperative call.
Drag and drop
Files from the OS: .on_file_drop(|path| Msg::Import(path)) receives
each dropped file at the pointer position (the deepest handler under the
pointer wins; when the platform reports no position, the first handler
in tree order receives it).
Within the app: mark sources .drag_source("payload") and targets
.on_drop(|payload| msg). Pressing a source starts the drag; releasing
over a target delivers the payload; releasing anywhere else cancels.
State layers
The kit’s controls share one interaction recipe — Material’s state layer.
.state_layer(|t| t.text) declares the content color (the ink drawn on the
control), and the framework veils it over the control’s container on hover
(8%), keyboard focus and press (12%), and drag (16%), so you never hand-pick a
hover color:
div()
.themed(|t, s| s.bg(t.surface_raised))
.state_layer(|t| t.text) // hover / focus / press / drag, one recipe
.press_scale() // a 0.97 tactile dip while pressed
The per-state closures are still there for full control
(.hover_themed / .active_themed / .focus_themed). Solid brand fills use
them to step the ramp (accent → accent_hover → accent_active) rather
than take a veil, because a light content veil would wash a saturated accent
out. Disabled controls fade their container and dim content to text_disabled.
Transitions
.transition(Transition::colors()) animates property changes between
frames (colors and shadows by default; opt into lengths/offsets/opacity).
Retargeting mid-flight continues from the current value.
Transition::spring() (or .with_spring(stiffness, damping)) swaps
the duration+curve pair for physical motion: underdamped springs
overshoot on lengths and offsets and settle on physics, while colors,
opacity, and shadows clamp at the target. .enter(transition) plays
an element in from transparent the first time its id appears — list
rows, toasts; exit animations are not supported yet. Theme switching
crossfades automatically wherever .transition(Transition::colors())
is set: the themed target changes, the retarget machinery does the
rest. Keyframes
timelines handle looping ambient motion (pulses, shimmer), sampled from
the frame clock; .spin(ms) rotates paths (spinners). Reduced motion
snaps everything, keeping headless renders deterministic.
The easing families follow Material 3 — EASE_STANDARD for two-way state
changes, EASE_DECELERATE for entrances, EASE_ACCELERATE for exits — and
durations sit on the MotionDuration scale (Micro 100 ms / Fast 120 /
Base 200 / Slow 300), with exit_ms running an exit ~25% quicker than its
entrance. Keyboard-driven changes snap: a keyboard-focused control shows its
ring and state layer instantly rather than lagging behind a fast keyboard
user.
Text and inputs
Text shapes through parley with three embedded Inter faces (400/500/600) for determinism; the windowed runner adds system fonts, which also provides per-script fallback (CJK works out of the box).
text("...") wraps to its container, truncates with .truncate(),
aligns with .text_align(..), and exposes the full editorial controls:
.size_px, .tracking, .leading, .family.
Reading measure
Long prose reads best in a column near ~66 characters per line, not the full
window. .measure(chars) caps an element’s width in CSS ch units (1ch is the
advance of '0' in its own resolved text style), so the column holds a
comfortable line at any window width — resolved to pixels during layout, where
the font metrics live. The default MEASURE_CH (52ch, tuned so the body face
renders ~66 characters) drives the kit’s reading_column(), the markdown
widget’s prose, and the ai_chat showcase. Because fenestra has no style
inheritance, set the container’s .size(..) and .family(..) to match the
prose it wraps, so the measure tracks the real glyphs.
Balanced & pretty wrapping
parley breaks lines greedily (fill each line as full as it goes); TextWrap
refines that result. .balance() (CSS text-wrap: balance) evens line lengths
by re-wrapping at the narrowest width that keeps the same line count — for
headings, titles, and pull quotes, not body copy. .pretty() (CSS text-wrap: pretty) nudges the width down just enough to pull a stranded last word up onto
the previous line, never adding a line (best-effort). Both re-break the
already-shaped layout — no glyph re-shaping — and Normal (the default) costs
nothing. The markdown widget balances headings automatically.
OpenType features
Numerals and glyph variants are typed builders, not CSS strings. Figure shape and figure spacing are orthogonal axes that compose freely:
.tabular()/.proportional_nums()— fixed-width (tnum) vs prose-spaced (pnum) figures. Tabular digits align in columns and don’t jump when a value updates in place; use them for tables, timers, and charts..lining_nums()/.oldstyle_nums()— uniform cap-height (lnum) vs ascending/descending text figures (onum) that sit naturally in serif prose..small_caps()(smcp),.ligatures(bool)(liga), and.fractions()(frac, turning1/2into a single glyph) are independent toggles.
Each feature is only as visible as the face supports it — the embedded Inter
carries tnum/pnum/frac; a registered serif such as Playfair adds
onum/lnum/smcp. The kit’s font_feature_specimen() shows each one side by
side against the font’s default.
Optical sizing
A variable font with an opsz axis carries several optical masters in one
file: sturdier, lower-contrast cuts for small text and finer, higher-contrast
cuts for large display sizes. OpticalSizing drives that axis:
.optical_auto()— the everyday choice (CSSfont-optical-sizing: auto):opsztracks the rendered size, so one face reads right from a 14px caption to a 64px headline..optical(OpticalSizing::Fixed(n))— pin one optical master at any size (specimens, deliberate contrast).- The default (
OpticalSizing::Default) sets no variation, so static faces — the embedded Inter, JetBrains Mono — and all existing output are untouched.
The fenestra-looks crate bundles Fraunces, a variable text serif with an
opsz axis (the warm-editorial look’s prose face); register your own variable
faces with Fonts::register. The opsz value is the only thing that changes
between the two specimens in the optical_sizing golden — same face, same size.
Single-line input
text_input(&self.value).placeholder("Search…").on_input(Msg::Set).id("q")
— parley editing with selection, caret blink, clipboard (Cmd/Ctrl A/C/X/V),
word jumps, Home/End, IME preedit, and horizontal follow-scroll. Control
characters are filtered on every path. The app owns the value.
Selection works the way fingers expect: drag selects, double-click selects the word, triple-click the line, shift-click extends from the caret, shift-arrows extend by character/word/line.
Undo and redo
Cmd/Ctrl+Z undoes, Shift+Cmd/Ctrl+Z (or Ctrl+Y) redoes — per field,
with the classic rules: typing coalesces into one undo unit; moving
the caret, clicking, pasting, or cutting starts a new one; a fresh
edit clears the redo stack; undo restores the selection too. History
is bounded (100 steps). Because the app owns the value, undo emits
on_input like any other edit — your update sees it.
Multiline
text_area(&self.notes).on_input(Msg::Notes).id("notes") wraps to its
width, accepts Enter as a newline, moves by line with the arrows, and
grows with its content from min_height. Cap growth with an outer scroll
container.
Selecting static text
.selectable() on text and rich text gives users browser-grade
selection: drag selects, double-click takes the word, triple-click the
line, Cmd/Ctrl+C copies. One selection lives at a time; any press
elsewhere clears it. The highlight uses the input selection color, and
tests read the selected byte range from AccessNode::selection.
Rich text
rich_text([span("Ship it "), span("boldly").weight(Weight::Semibold) .color(theme.accent), span(" today").italic()]) — one wrapped
paragraph, per-span weight/color/size/family/italic, spans flowing
together across line breaks. Display-only (inputs stay plain), and the
spans concatenate into one accessible label.
Emoji
Color emoji (COLR/sbix) render through system-font fallback
(Fonts::with_system, the windowed default) — pixel-proven on macOS.
Embedded fonts have no emoji (determinism trades for coverage, same as
CJK). Known caveat: VS16 emoji-presentation sequences (like ❤️ =
U+2764+FE0F) currently select the monochrome text glyph.
Bidi and RTL
parley shapes mixed-direction text (Arabic/Hebrew embedded in Latin
and vice versa) out of the box; glyph coverage for RTL scripts comes
from system fonts (Fonts::with_system), exactly like CJK. UI
mirroring (flipping layout direction app-wide) is not implemented yet.
IME
Composition works in both inputs: preedit shows inline with an underline, commit inserts atomically. The windowed runner anchors the OS candidate window to the caret as you type, in every window.
Overlays
An overlay is a child element marked .overlay(def): it leaves normal
flow, lays out against the canvas, positions relative to its anchor (the
parent), paints above everything, and hit-tests first.
The modes:
Overlay::menu()— click the anchor to toggle; closes on outside click, Escape, or choosing a clickable inside (selects use this).Overlay::tooltip()— shows after a hover delay; never hit-tested.Overlay::modal()— open while present in the tree (app-driven), with backdrop and a focus trap; outside click/Escape emiton_close.Overlay::toasts()— app-driven stack pinned top-right; nothing closes it from outside.Overlay::context()— app-driven like modal, no backdrop; pins at the pointer position the moment it opens (right-click menus).
Kit wrappers: tooltip(target, text), modal(title), toast_stack(..),
dropdown_menu(items), context_menu(items), popover(content), and
combobox(value, open, options) — an editable select whose typing
filters the listbox.
Nested overlays (a select inside a modal) work. Enter animations are
200 ms fade (+slide for centered overlays); reduced motion snaps them.
Images and icons
image_rgba8(width, height, pixels) shows straight-alpha RGBA8, stretched
to the element rect and clipped to the corner radius — .rounded_full()
turns a square avatar into a circle. Equality is blob identity, so
rebuilt views stay cheap. Decode files with the image crate and hand
fenestra the pixels.
Icons are vector paths painted in the resolved text color:
icons::{check, chevron_down, chevron_right, x, search, circle_dot}— the 16px built-ins the kit itself uses.icons::lucide::*— 24 vendored Lucide icons (ISC), stroked at 2px with round caps;lucide::all()iterates them.path(bez, viewbox, stroke)— bring your ownkurbo::BezPath;.trimanimates draw-on,.spinrotates.
Commands and async
update is synchronous on purpose. Background work flows in through the
command proxy:
impl App for Clock {
type Msg = Msg;
fn init(&mut self, proxy: Proxy<Msg>) {
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_secs(1));
proxy.send(Msg::Tick);
});
}
// ...
}
Proxy<Msg> is cloneable and thread-safe; sends wake the event loop,
apply through update, and repaint. After the window closes, sends drop
silently. (A::Msg: Send — messages cross threads.)
This composes with anything async: spawn a tokio runtime in init, keep
the proxy in your tasks; or pair with rfd’s file dialogs
(examples/file_dialog.rs). Headlessly, render_app drains proxied
messages at deterministic points, so init-time sends are testable.
Toast auto-dismiss is the canonical pattern: push the toast, spawn a
timer thread, send the removal message (examples/toasts.rs).
Multiple windows
Secondary windows are app state, exactly like overlays: [App::windows]
returns the set that should be open, and the runner reconciles it after
every update — new keys open a window, removed keys close one, changed
titles apply live.
fn windows(&self) -> Vec<WindowDesc<Msg>> {
self.open
.iter()
.map(|&i| WindowDesc::new(
format!("probe-{i}"), // stable key
format!("Inspector — {}", NAMES[i]), // title, live-updated
(380.0, 260.0), // logical size at open
Msg::CloseInspector(i), // the OS close button
))
.collect()
}
fn view_for(&self, key: &str) -> Element<Msg> {
match key {
MAIN_WINDOW => self.view(),
key => self.inspector(key),
}
}
There is one update and one source of truth: a message from any window
mutates the same app state, and every window repaints from it. Each
window keeps its own retained state — focus, scroll offsets, text
editors — keyed by your stable key, plus its own IME anchor and
accessibility tree.
The OS close button closes nothing by itself: it emits on_close, and
your update removes the desc. That means you can intercept — confirm,
save, or veto by keeping the desc in the list.
Notes: native only (the web runner ignores windows()); view_for
defaults to view(), so single-window apps never see this API.
examples/windows.rs is the working pattern.
Per-window themes: override theme_for(&self, key) -> Theme (defaults
to theme() everywhere) — a dark inspector next to a light main window
is one match away. The runner consults it per window; the test harness
keeps its single explicit theme for determinism.
Embedding in your wgpu app
The batteries-included runner (fenestra::run) is the easy path. The
narrow waist underneath it is Embedded: run a fenestra App inside
an event loop, device, and surface that you own — a game, an engine
editor, an existing wgpu tool.
use fenestra::shell::{Embedded, wgpu, winit};
// Once, on your device (the renderer compiles vello's shaders here):
let mut ui = Embedded::new(MyHud::default(), Theme::dark(), &device, surface_format);
ui.set_clear(Color::TRANSPARENT); // your scene shows through
// Every winit event:
let response = ui.handle_window_event(&window, &event);
if response.repaint { window.request_redraw(); }
if response.consumed { return; } // fenestra took it — skip your handling
// Every frame, after your own passes:
ui.render(&device, &queue, &surface_view, (width, height), scale_factor);
What the pieces mean:
renderbuilds the frame, paints it with vello into an internal premultiplied-alpha texture on your device, and composites onto the target view with alpha blending. With a transparent clear, the UI floats over whatever you drew first. For custom compositing (sampling the UI in your own pipeline), taketexture_view()instead and skip the built-in blit.handle_window_eventuses the same winit translation as the runner — printable/shortcut keyboard split, IME commit/preedit, modifier tracking, wheel conventions.EventResponse.consumedis the arbitration contract: true when the pointer is over fenestra content or a widget holds keyboard focus.input(InputEvent)is the window-system-agnostic layer beneath it — what non-winit hosts and tests drive.pump()drains proxied messages (fromApp::init/ threads);animating()says whether to keep scheduling frames;frame()exposes the last frame for semantic queries and inspector dumps — embedded UIs are just as verifiable as windowed ones.
Version-matching matters: integration code must use the same wgpu and
winit fenestra was built with, so the shell re-exports both
(fenestra::shell::{wgpu, winit, vello}).
Out of scope in embedded mode (use the runner): secondary windows
(App::windows) and IME candidate-window positioning.
examples/embedded.rs is a complete host app.
Writing a widget crate
fenestra widgets are plain functions returning Elements — publishing
a widget crate needs no traits to implement, no macros, no registration.
fenestra-charts is the
reference: read its source next to this checklist.
The contract
-
Depend on
fenestra-coreonly. Widgets are pure tree builders; they never need the runner. Pullfenestra-shellin as a dev-dependency for golden tests.[dependencies] fenestra-core = "0.10" [dev-dependencies] fenestra-shell = "0.7" -
Take colors from the theme, never hardcode. Style through
.themed(|t, s| s.bg(t.elevated_surface(1)).border(1.0, t.border_subtle))— your widget then works in light, dark, and every generated theme, including ones that don’t exist yet. -
Stay Elm-pure. Widgets own no state: take the current value and emit messages (
on_pick(impl Fn(..) -> Msg)); the app stores. If your widget seems to need internal state, it needs a value + handler pair instead. -
Simple widgets are functions, configurable ones are builders.
sparkline(values) -> Element<Msg>for one-liners; a struct with methods +impl From<W<Msg>> for Element<Msg>once there are options (look atcomboboxordata_tablein the kit). -
Name things for queries. Set
.semantics(..)and.label(..)on every meaningful node — that’s what makesby::role(..).name(..)find your widget in users’ tests, and what screen readers announce. Give stateful nodes stable.id(..)s. -
No panics, ever. Hostile input (empty lists, NaN, negative sizes) renders something sane. Test it:
let image = render_element(sparkline([f32::NAN]), &theme, (200, 50)); -
Golden-test the look. One snapshot per widget family:
assert_png_snapshot(snapshot_dir(), "charts", &image);Goldens travel with the crate; users see exactly what they get, and your CI catches visual regressions on the reference platform.
-
Drive behavior through the harness, not coordinates:
let mut h = Harness::new(app, Theme::light(), (400, 300)); h.click(&by::role(Semantics::Button).name("sort by name"));
Versioning
Track fenestra’s minor version (fenestra-core = "0.10") and re-test on
each release; the core IR and builder vocabulary are the stable
surface. After 1.0, semver does the rest.
Accessibility
Every interactive kit widget exposes its role, state, name, and value. Custom elements opt in:
div()
.on_click(Msg::Open)
.semantics(Semantics::Button)
.label("Open settings")
Text, image, and input leaves project automatically. Icon-only buttons
need .label("...") — they have no accessible name otherwise.
Two consumers see this tree:
- Tests —
frame.access_tree()returns plain data (AccessNode: id, semantics, label, value, rect, focusable, children). Assert your screens are labeled, in CI, with no platform involved. - Assistive technology — the windowed runner drives an AccessKit adapter: the tree pushes after every frame, and screen-reader activation (Click/Focus actions) routes back through your messages.
Live regions: .live() marks an element whose content changes should
be announced without focus moving there; the kit’s toasts set it
themselves. Text inputs expose their selected byte range (collapsed =
caret) on AccessNode::selection — assert selection state headlessly.
Out of scope so far: the full screen-reader text-editing protocol (per-character inline text boxes for braille routing and character-by-character navigation). Field-level value, caret, and selection are exposed; run-level geometry is not.
Headless rendering and testing
The thesis feature: a fenestra UI can be driven, inspected, and rendered to pixels without a display server — by a test, a CI job, or an AI agent. Assertions work at three levels, and one harness carries all of them:
- Structure — the accessibility tree (roles, names, values, rects).
- Behavior — the messages the UI emits into
update. - Pixels — deterministic PNGs compared against goldens.
The harness and semantic queries
Find widgets the way users do — by role and accessible name — instead of by coordinates:
use fenestra::prelude::*;
use fenestra::shell::Harness;
let mut h = Harness::new(app, Theme::light(), (480, 320));
h.click(&by::role(Semantics::Button).name("Add"));
h.type_text("buy milk");
h.key(KeyInput::plain(Key::Enter));
assert!(h.query(&by::label("buy milk")).is_some()); // structure
assert_eq!(h.take_messages().len(), 3); // behavior
let png = h.render(); // pixels
Queries follow the Testing Library priority: prefer by::role(..) +
.name(..), then by::label(..), then by::value(..); by::id(..)
(your .id("...") keys) is the escape hatch users can’t see. Lookups
are strict like Playwright locators — get panics on zero or several
matches, and the panic message contains the whole accessibility tree,
so the failure explains itself. query returns Option (assert
absence), get_all returns every match.
Verbs: click, right_click, double_click, hover, type_text,
key, tab/shift_tab, focus, drag(from, to), drop_file,
wheel. The clock is explicit — pump(ms) advances animations exactly
that far; nothing is painted unless you call render(), so structural
tests stay fast.
Multi-window apps test whole: the harness reconciles [App::windows]
after every update; activate_window(key) scopes the verbs,
render_window(key) snapshots any window at its own size.
The inspector
h.frame().debug_tree() dumps one line per node — kind, #key, layout
rect, scroll/focus flags, semantics, and src=file:line (the builder
call site, captured with #[track_caller]). It is the headless
equivalent of a visual-tree inspector; grep it.
h.frame().access_yaml() emits the accessibility tree in Playwright’s
aria-snapshot grammar (- button "Save"), ready for insta snapshots:
- text "Inbox"
- textbox [value="draft text"] #draft
- button "Send"
Scenario scripts (no Rust required)
run_scenario drives a harness from JSON — the loop an agent reaches
for between code changes:
{"steps": [
{"click": {"role": "button", "name": "Add"}},
{"type": "buy milk"},
{"key": "enter"},
{"assert": {"exists": {"label": "buy milk"}}},
{"shot": "after-add"}
]}
Targets use the query vocabulary (role, name, label, value,
id, plus _contains forms); asserts cover exists / absent /
count / value / windows; shot writes named PNGs. Typos are
parse errors, not skipped steps, and every failure carries its step
index and the accessibility tree.
Golden tests
assert_png_snapshot(dir, name, &image) compares against a committed
PNG (3/255 per channel, 0.2% pixel budget). On failure, three artifacts
land next to the golden: <name>.actual.png, <name>.diff.png (the
offending pixels in red over the dimmed golden — where, not just how
much), and <name>.side.png (golden | actual | diff). The panic
message carries the counts, the budget, and the worst pixel’s
coordinates. FENESTRA_UPDATE_SNAPSHOTS=1 regenerates — look at the
images before committing. Passing runs clean stale artifacts up.
The exact guarantees behind all of this — what “deterministic” means, where the boundaries are — live in the determinism contract.
fenestra’s own kit is verified this way: every widget, every state, both themes, on every push, plus property tests that layout never panics on arbitrary trees, Tab order is a permutation, and widget ids stay unique per frame.
The classic forms
render_element(view, &theme, size) renders one tree;
render_element_with takes custom fonts; render_app(app, &events, size, &theme) replays coordinate-level SyntheticEvents and renders a
settle frame — it is a thin wrapper over the harness, kept for simple
pixel probes.
Performance
fenestra rebuilds the visible tree every frame — no diffing, no retained
widget graph. That stays honest because the work is small (fractions of a
millisecond for real screens; see BENCHMARKS.md in the repo) and
because the two places where rebuilding would hurt have dedicated
machinery: idle frames and long lists.
Clean-frame memoization
The windowed runner keeps the last painted scene, keyed by
(logical width, height, scale). When the OS asks for a redraw and
nothing has changed — the window was exposed, un-occluded, or a timer
fired — it re-presents that scene and skips build, layout, and paint
entirely.
“Changed” is tracked by a dirty flag, set by input events, app updates,
accessibility focus, hover refresh after scrolls, resize, scale change,
and resume. While anything is time-driven — a caret blinking, a spring
settling, a spinner, a tooltip waiting out its delay, a scrollbar fading
— frame.animating keeps the flag set, so memoization can never starve
an animation.
Two consequences worth knowing:
- An idle fenestra app costs approximately zero CPU per OS redraw.
- Headless rendering (
Harness,render_element) never memoizes: tests always exercise the full pipeline, so a memoization bug cannot hide from goldens.
There is deliberately no caching below the frame level. Caching a subtree’s scene requires proving its paint is a pure function of its retained inputs; that purity is not tracked per-subtree today, and a wrong cache means stale pixels. The decision and its revisit conditions are recorded in ARCHITECTURE.md.
Virtualization
virtual_list(count, row_height, |i| row) materializes only the rows
overlapping the viewport (plus overscan), with spacer elements keeping
scroll geometry exact. Row count stops mattering: 100k rows lay out in
~0.09 ms.
virtual_list_variable(count, estimated_height, |i| row) handles rows
whose heights vary or are unknown up front. It keeps a prefix-sum index
of row heights, seeded with your estimate:
- Rows are placed from the index — top spacer, realized rows, bottom spacer.
- After layout, each realized row’s measured height is recorded back into the index.
- The next frame’s placement uses the corrected sums.
So offsets, scrollbar geometry, and the total height converge to the truth as the user scrolls; by the time a region has been seen once, its geometry is exact. The estimate only positions rows the first time they appear — it should be in the right ballpark (the typical row), not precise. Sub-quarter-pixel measurement jitter is ignored, so the index never thrashes.
Constraints, both variants: rows are keyed by index (retained state follows the index, so prepending shifts identity), and overlays inside rows are unsupported. Variable rows must size themselves — fixed heights or content with intrinsic height.
What to reach for, in order
- Nothing. Measure first;
cargo run --release --example benchshows what full rebuilds actually cost. - Virtualize any list that can grow past a few hundred rows.
- Check
animating. If your app never goes idle, something is keeping the flag set — an always-on spinner or an indeterminate progress bar costs a full pipeline pass per frame, on purpose.
The determinism contract
Verification is only as good as its reproducibility. This page states exactly what fenestra guarantees about headless output, where the boundaries are, and why — so you can decide what to stake on it.
The guarantee
Given the same element tree, theme, logical size, scale, and font set, a headless render produces the same pixels on the same GPU class, run after run. Everything that could drift is pinned:
- Fonts —
Fonts::embedded()ships three Inter faces inside the crate; text shaping and metrics cannot vary with the host system. (Fonts::with_system()deliberately trades this for CJK and emoji coverage — windowed apps want it, goldens should not.) - Scale — headless renders at 1.0; no DPI surprises.
- Motion — reduced motion is forced; animations resolve to their
end states. The harness clock is explicit:
pump(ms)advances it, nothing else does, so mid-animation frames are reproducible at exact timestamps. - State — a fresh
FrameState(in-memory clipboard, no focus, no scroll) unless your test built some up on purpose. - Sizes — clamped to the device texture range (≥1, typically ≤8192), so a wild request degrades predictably instead of failing.
The boundaries (honest part)
- Across GPU classes, pixels wobble. Antialiased edge coverage is
floating-point work; Metal, lavapipe, and other rasterizers disagree
by a hair. The golden comparator absorbs this: 3/255 per channel,
0.2% of pixels — and CI’s software rasterizer widens the pixel budget
via
FENESTRA_SNAPSHOT_BUDGET=0.006without loosening the reference platform. Goldens in this repo are rendered on macOS/Metal; that is the reference. (Flutter reached the same conclusion and moved to a triage service; Slint gets exact pixels only on a reduced CPU renderer. fenestra keeps the production renderer and states budgets.) - Dark themes wobble more. Low-luminance antialiasing amplifies rounding differences; the budget covers it, but expect dark goldens to sit closer to the threshold.
- The structural layers do not wobble at all. The accessibility
tree,
debug_tree, query results, and emitted messages are exact on every platform — prefer them when an assertion doesn’t need pixels. - Wall-clock leaks are a bug. If you find output that varies with time of day, machine speed, or run order, file it; determinism regressions are treated as breakage, not flake.
What this buys
A UI test that fails only when the UI changed; PNGs an agent can diff
to see its own work; goldens that survive git bisect; and a CI
matrix where the one honest source of variance (the rasterizer) is
named and budgeted instead of fudged per-OS.
Trust and security
What you can verify about fenestra before staking anything on it — and where the honest boundaries are.
Code
- No unsafe code.
unsafe_code = "forbid"across the workspace — not a guideline, a compile error. Memory-safety risk lives in the dependency tree (wgpu, vello, parley, winit), which is the explicit trust boundary: mature, widely-deployed Linebender/gfx-rs projects. - Totality under hostile input. Property tests assert that any element tree at any viewport builds and paints without panicking; weekly fuzzing (libFuzzer) hammers theme-file parsing, layout, and the text-input pipeline. Scenario JSON and theme files reject unknown fields rather than guessing.
- MSRV is declared and enforced:
rust-version = "1.88", built in CI on exactly that toolchain. Minor releases may raise it; the CHANGELOG records when.
Supply chain
-
cargo audit(RustSec advisories) andcargo deny(license allowlist, registry pinning, ban rules) run on every push and weekly. -
Every GitHub Actions step is pinned to a full commit SHA, resolved at integration time.
-
Releases publish from CI on tagged commits after the full gate suite re-runs. The packaged
.cratefiles are attached to each GitHub release with provenance attestations — verify one with:gh attestation verify fenestra-core-*.crate --repo richer-richard/fenestra
Quality gates
- rustfmt, clippy
-D warnings, and the full test suite on macOS/Metal (the golden-reference platform), Linux/lavapipe, and Windows (compile + core tests; the WARP rasterizer’s instability is documented, not hidden). - Golden pixel tests with explicit budgets — the determinism contract states exactly what is guaranteed.
- A line-coverage floor on
fenestra-coreenforced in CI (raised as coverage grows; never lowered without a recorded decision), and performance gates with generous ceilings that catch order-of- magnitude regressions without flaking on shared runners.
Reporting
Vulnerabilities: use GitHub’s private reporting (Security → Report a vulnerability). SECURITY.md has scope and response expectations.