Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Kael 0.4 · Rust application framework

One codebase.
Every serious surface.

Kael is a retained, GPU-accelerated framework for building ambitious applications in Rust. Design the interface once, keep the product logic once, and run it as a native desktop app or a WebAssembly application in the browser.

macOSWindowsLinuxBrowser

Your application

Views · State · Product logic One typed Rust architecture
macOSMetal
WindowsDirect3D 11
LinuxVulkan
BrowserWebGL2

The same retained scene, adapted at the platform boundary.

01

I wanted a foundation for the kind of applications I care about building: documents, sheets, presentations, whiteboards, creative tools, engines, and workspaces that stay fast as they become more capable.

I also wanted the web build to be the same product, not a second interface maintained beside the first. Kael keeps rendering, state, input, accessibility, documents, platform services, testing, and release engineering inside one Rust system.

Augustus Otu
Creator of Kael

Read the full story
02
See every framework layer

Proof, not promises

Scale is part of the release contract.

Kael’s maintained workloads exercise the actual retained rendering path on native and browser targets. The limits are checked in release tooling, not estimated from isolated widgets.

1,000,000logical table rows64 or fewer mounted
100,000whiteboard shapesspatially culled
34publishable cratesone release line
4GPU render targetsone retained scene
Inspect the suite-scale workload
03

Application state, layout, components, painting, virtualization, animation, document bytes, workers, and most product services compile from the same source. Native-only abilities remain visible through typed capability reports, so fallbacks are intentional.

Shared Views, state, scenes, components, input, files, documents, realtime networking

Adapted Windows, GPU presentation, storage, printing, capture, audio, WebViews

Explicit Subprocesses, arbitrary native paths, system keychains, detached OS windows

Understand the portability contract

Start with a real app

From an empty directory to two targets.

Kael’s CLI generates a project whose entry point is already arranged for native and browser builds.

$ cargo install kael-cli
$ kael new my_app
$ cd my_app

# Native desktop
$ cargo run

# Browser
$ kael web serve

Choose your path

Build the product, not the plumbing.

Kael is open source under Apache-2.0. View the source on GitHub.

Kael began as a fork of GPUI by Zed Industries and is now an independent project. It is not affiliated with or endorsed by Zed Industries.

Why Kael exists

Kael started from a personal frustration: ambitious desktop software too often has to choose between native capability, web reach, performance, and a coherent development model.

I wanted to build products with the depth of a document suite or creative engine: large sheets, long documents, presentations, whiteboards, media, collaboration, and rich input. I did not want an application that constantly redraws, carries several runtimes, or needs a second frontend to reach the web.

That is the idea behind Kael: one Rust application architecture, a retained GPU scene, explicit resource bounds, and platform services that tell the truth about where they can run.

The product I wanted to build with

Kael is designed for software that grows. A useful framework has to remain clear when the application has many screens, many windows, background work, large data, documents, plugins, native services, and years of product decisions.

That led to a few non-negotiable principles:

  • Do useful work. Invalidation, frame skipping, localized damage, virtualization, recycling, bounded caches, and GPU budgets should prevent work that cannot improve the current frame.
  • Keep one architecture. Views, state, async work, files, networking, documents, diagnostics, packaging, and updates should share Rust types and failure handling.
  • Let products own their identity. Use kael_ui, reshape its tokens and components, mix it with custom work, or build an entire design system directly on kael primitives.
  • Treat platforms honestly. A capability report is better than an API that exists everywhere but quietly does something weaker on one target.
  • Prove scale in releases. Large tables, documents, slide decks, whiteboards, browser engines, and native renderers belong in maintained gates, not only in roadmap language.

Why desktop and web share a foundation

The browser should not require a rewrite of the product. Kael sends the same retained scene to native GPU backends or a dedicated WebGL2 renderer. State, layout, components, painting, virtualization, animations, document bytes, and workers stay in Rust.

The platform boundary remains real. A browser cannot create a detached OS window, expose arbitrary native paths, launch subprocesses, or provide a system keychain. Kael represents those differences through capabilities and portable byte-oriented workflows instead of hiding them.

Read One codebase, desktop and web for the exact contract.

Why retained rendering

Immediate-mode UI is excellent for many tools, but Kael is aimed at long-lived product surfaces where identity, focus, accessibility, text, window state, and large collections benefit from stable retained structure.

Reactive Entity<T> state invalidates affected views. Scene fingerprints can skip unchanged frames. Damage can remain local. Virtual lists mount only the visible range. Hidden and idle windows stop requesting frames. These mechanisms do not make every application fast automatically, but they give a product direct control over where time and memory go.

Why the framework is layered

kael provides rendering, state, elements, layout, text, input, accessibility, windows, async work, and platform primitives. kael_ui provides a broad, brandable component system. Focused kael_* crates add storage, networking, secrets, documents, diagnostics, notifications, sharing, media, release services, and application engines.

The dependency direction is deliberate: kael_ui depends on kael; kael never depends on kael_ui. A custom visual system is an architecture choice, not an escape hatch.

Where Kael fits

Kael is a strong fit for editors, IDEs, agent workspaces, communication apps, dashboards, database clients, document suites, media tools, design software, simulations, and game or creative engines where responsiveness, native services, and product architecture all matter.

Choose another stack when a product depends primarily on DOM-only packages, the npm ecosystem is the main advantage, an immediate-mode UI is the better mental model, or a required platform capability is not yet implemented in Kael.

Kael 0.4 is pre-1.0. Pin a compatible minor release, validate the capabilities your product needs, and expect refinement before 1.0.

Foundation and independence

Kael began as a fork of GPUI, created by Zed Industries, and was previously distributed as the adabraka GPUI fork. It retains the required Apache-2.0 attribution for that foundational work.

Kael is now an independent project with its own application model, browser renderer, component system, product crates, performance workloads, platform bridges, and release process. It is not affiliated with or endorsed by Zed Industries.

Augustus Otu, creator of Kael

What Kael is today

Kael 0.4 is a pre-1.0 application framework for substantial Rust products. It is larger than a widget toolkit and more focused than a collection of unrelated libraries: the renderer, application runtime, product services, and release tooling are designed to share types, bounds, errors, and platform truth.

This page is the current map. Follow the linked guides when you need the API contract or platform details.

The application foundation

kael owns the retained application model:

  • GPU scenes on Metal, Direct3D 11, Vulkan through Blade, and browser WebGL2;
  • windows, elements, flex and grid layout, text shaping, images, SVG, canvas, paths, effects, clipping, transforms, and high-DPI presentation;
  • reactive Entity<T> state, contexts, observation, async executors, actions, keybindings, focus, drag and drop, and multi-window lifecycle;
  • mouse, keyboard, wheel, gesture, game, touch, and pen input through shared contracts;
  • AccessKit semantics on desktop and a bounded retained ARIA mirror in browsers;
  • invalidation, frame skipping, localized damage, bounded atlases, renderer batching, and display-aware scheduling.

Start with Core concepts, Layout and styling, and Canvas and graphics.

The product interface layer

kael_ui is optional and brandable. It adds controls and compositions without making the primitive crate depend on a visual identity:

  • buttons, text inputs, text areas, selection controls, date and color inputs;
  • menus, popovers, dialogs, tooltips, tabs, splitters, scrollbars, and toasts;
  • virtual lists, recycling lists, data tables, charts, editors, navigation, and responsive layout helpers;
  • theme tokens, runtime theme switching, accessibility behavior, and reduced motion policies;
  • opt-in media, Markdown/HTML rendering, syntax editing, game input, and screen capture feature sets.

Use the Component library as the index. Building an entire design system directly on kael remains a first-class choice.

Scale and graphics

Large logical workloads do not need large retained trees. Kael provides uniform and variable-height virtualization, adaptive recycling pools, compressed table selection, spatial indexing, tile damage, bounded caches, and GPU memory budget queries.

PortableScene2d records a bounded high-throughput 2D scene shared by native and browser renderers. It supports up to 100,000 retained quads, sprites, filled paths, or triangle objects with transforms, clips, opacity, and transactional rollback. Unsupported custom shaders, compute, blend modes, or 3D work return typed results instead of pretending to be portable.

For simulations and engines, FixedFrameClock provides bounded fixed-timestep catch-up, interpolation, pause and resume, and dropped-time telemetry. Read Lists and large data, Animations, and Game input.

Product services

Focused crates keep capabilities optional. Applications compile only the batteries they select.

AreaCrates and capabilities
Datakael_storage, kael_cache, kael_secrets: SQLite, IndexedDB, JSON, bounded caches, OS credentials
Documentskael_document, kael_office, kael_pdf, kael_markdown: recovery, versions, byte import and export, OOXML, PDF, structured Markdown
Networkkael_http_client, kael_net: HTTP, bounded WebSockets, sync primitives, host policy
Mediakael_audio, kael-media, kael_media_engines: mixing, capture, playback, timelines, compositing, export foundations
Operationskael_diagnostics, kael_notifications, kael_share, kael_release: logs, metrics, crashes, notifications, sharing, signed updates
Renderingkael_render_graph, kael_gpu_budget: pass scheduling, invalidation, GPU memory budgets
Product foundationskael_engines, kael_i18n, kael_icons: bounded editor and workload state, localization, typed icons

The Platform APIs, Office and PDF, and Realtime networking guides cover the shared contracts and their platform boundaries.

Desktop and browser delivery

New CLI projects use one main.rs for native and browser builds. The same view, state, layout, components, retained scenes, virtualization, animation, document bytes, and worker requests compile to both targets. The host adapts GPU presentation, windows, files, storage, printing, capture, audio, notifications, sharing, and WebView composition.

The browser backend is not a DOM rewrite or an application inside a desktop WebView. It is a dedicated WebGL2 renderer with its own text atlas, IME and clipboard bridge, retained accessibility mirror, file-byte workflows, Web Workers, IndexedDB storage, AudioWorklets, WebSockets, capture, printing, and sandboxed iframe WebView islands.

Read One codebase, desktop and web before selecting a platform-sensitive workflow.

Testing and release engineering

Kael treats release readiness as code. The repository checks extracted crates.io packages, platform compilation, real native renderer windows, browser engines, WebView hosts, optimized Wasm, accessibility bounds, large workloads, installer contents, and signed update metadata.

Application tooling covers DMG, MSI, and AppImage packaging; macOS signing and notarization; Windows signing; checksums; update manifests; and atomic signed installation. These tools package Kael applications. The application owner still defines release policy and credentials.

Use Testing, Benchmarking evidence, and the Release process when preparing a product.

Current boundaries

Kael is broad, but it does not erase the operating system:

  • browser secondary windows are retained surfaces inside the page, not detached operating-system windows;
  • browser builds cannot expose arbitrary native paths, subprocesses, or a system keychain;
  • full Office layout, spreadsheet calculation, and slide layout engines remain product layers above Kael's bounded OOXML byte foundation;
  • custom shaders, compute, custom blending, and 3D are native or application-specific extensions rather than part of PortableScene2d;
  • some native touch, pen, media, sharing, and desktop-environment services vary by backend and must be checked at runtime;
  • the public API is pre-1.0 and can change between minor releases.

CapabilityReport::current() and WebViewCapabilityReport make those differences inspectable. Kael prefers an actionable Unsupported result over an API that silently behaves like a different feature.

Object guide

Use this page when you know what you want to build but not which Kael object to reach for.

Application objects

ObjectPurposeUse it for
ApplicationStarts and owns the runtimeProcess startup and the main event loop
AppAccesses application servicesWindows, tasks, files, clipboard, and global state
WindowHandles one rendered surfaceFocus, input, drawing, and window operations
WindowOptionsDescribes a new windowSize, title, appearance, and placement
CapabilityReportReports platform supportChoosing a native path or a portable fallback

State and rendering

ObjectPurposeUse it for
Entity<T>Owns reactive application stateModels that outlive one render call
Context<T>Reads and updates an entityListeners, notifications, tasks, and child entities
RenderTurns state into elementsViews with retained state
RenderOnceTurns a value into elements onceSmall value components and builders
IntoElementConverts a value into a UI elementReturn types from render methods
SubscriptionKeeps an event listener aliveObserving entities, windows, and application events
FocusHandleIdentifies a focus targetKeyboard input and focus movement

The normal update path is:

input → entity.update(...) → cx.notify() → Render → retained scene → platform renderer

Layout and components

Object or functionPurposeUse it for
div()Creates the base layout elementFlex, grid, spacing, color, borders, and children
StyledAdds style methodsSize, alignment, typography, and visual state
kael_ui::initRegisters the component systemAny app that uses kael_ui controls
ThemeHolds component design tokensProduct colors, type, radius, and density
Button, Input, SelectReady made controlsCommon interactive UI
AppShellCreates an application frameSidebars, toolbars, and main content

Large data and graphics

Object or functionPurposeUse it for
uniform_listVirtualizes equal height rowsLogs, feeds, and simple tables
listBuilds a measured listRows with variable height
VirtualListAdds higher level virtual list behaviorProduct lists using kael_ui
VirtualSheetGridVirtualizes rows and columnsSpreadsheet surfaces
canvasRuns custom retained drawingCharts, whiteboards, and editors
SceneStores platform render primitivesLow level retained output
PortableScene2dDescribes portable 2D scene dataGame and simulation foundations

Async, files, and web content

ObjectPurposeUse it for
Task<T>Represents scheduled async workFetching, parsing, saving, and delayed updates
BackgroundExecutorRuns work away from UI renderingCPU work that should not block a frame
ExternalFileCarries a file name, type, and bytesPortable open and drop workflows
PrintJobDescribes printable contentNative print dialogs and browser printing
WebViewHosts a web owned surfaceCompatibility islands and existing web products

Start with Application, one root Entity, and a Render view. Add services only when the feature needs them. See Core concepts for the working code and One codebase for platform boundaries.

Getting Started

Prerequisites

  • Rust 1.97.1 or newer with Rust 2024 support. The repository pins the supported toolchain in rust-toolchain.toml.
  • macOS: Xcode Command Line Tools. Release builds using precompiled Metal shaders require the full Xcode application; development builds can use the kael/runtime_shaders feature.
  • Windows: Visual Studio Build Tools with the Desktop development with C++ workload.
  • Linux: Vulkan, Wayland/X11, font, keyboard, D-Bus, and udev development packages. WebView, audio, capture, and media features add GTK/WebKitGTK, ALSA, PipeWire, and FFmpeg packages. The repository's install-linux-deps.sh is the canonical Ubuntu/Debian list.
  • Browser: the wasm32-unknown-unknown Rust target and wasm-bindgen-cli 0.2.122. Optimized release builds also use Binaryen 132. Projects created by kael new request the Rust target automatically through their toolchain file.

With only the macOS Command Line Tools installed, enable runtime shader compilation during development:

cargo run --features kael/runtime_shaders

Create an application

The CLI creates a small application using the core framework and optional UI component layer:

cargo install kael-cli
kael new my_app
cd my_app
cargo run

The generated project uses that same source for the browser target. Install the packager once, then build and open it locally:

cargo install wasm-bindgen-cli --version 0.2.122 --locked
npm install --global binaryen@132.0.0
kael web serve

Use kael web build for optimized dist/web deployment files. See Browser (WebAssembly) for target-specific dependencies and the initial browser capability boundary.

To configure a project manually, choose the layer you need:

[dependencies]
kael = "0.4"
kael_ui = "0.4" # remove this line when building a custom component system

kael_ui depends on kael; the core framework never depends on kael_ui.

Your first window

//! Compiled source for the Getting started and One codebase guides.

use kael_ui::prelude::*;

struct Counter {
    count: i32,
}

impl Render for Counter {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let counter = cx.entity();

        div()
            .size_full()
            .flex()
            .flex_col()
            .items_center()
            .justify_center()
            .gap_4()
            .child(div().text_3xl().child(format!("Count: {}", self.count)))
            .child(
                Button::new("increment", "Increase").on_click(move |_, _, cx| {
                    counter.update(cx, |state, cx| {
                        state.count += 1;
                        cx.notify();
                    });
                }),
            )
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    Application::try_new()?.run(|cx| {
        kael_ui::init(cx);
        install_theme(cx, Theme::dark());

        if let Err(error) = cx.open_window(WindowOptions::default(), |_, cx| {
            cx.new(|_| Counter { count: 0 })
        }) {
            eprintln!("failed to open the application window: {error}");
            cx.quit();
        }
    });
    Ok(())
}

What just happened

  1. Application::try_new() initializes the selected native or browser platform and returns startup failures instead of panicking.
  2. kael_ui::init(cx) registers the component systems used by the optional UI layer.
  3. cx.open_window(...) creates a GPU-rendered native window or the browser's #blade canvas window.
  4. cx.new(...) stores the view in a reactive Entity<Counter>.
  5. entity.update(...) mutates the model, and cx.notify() invalidates the affected view so Kael can render the next state.

Core patterns

Compose elements in Rust

Elements use a typed builder API for layout and appearance:

div()
    .flex()
    .flex_col()
    .gap_4()
    .p_4()
    .rounded_lg()
    .bg(rgb(0x1e1e1e))
    .text_color(rgb(0xffffff))
    .child("Hello")

Keep state in entities

entity.update(cx, |state, cx| {
    state.count += 1;
    cx.notify();
});

Treat platform support as data

use kael::{CapabilityReport, PlatformFeature};

let capabilities = CapabilityReport::current();
if capabilities.is_supported(PlatformFeature::GlobalHotkeys) {
    // Enable the native workflow.
} else {
    // Keep a deliberate fallback or explain the platform requirement.
}

Add only the batteries the product needs

WebView, media, storage, documents, diagnostics, icons, PDF, notifications, sharing, and other integrations are feature-gated or provided by focused support crates. Start from the smallest dependency set and add capabilities when the product requires them.

Next steps

One codebase, desktop and web

Yes: the aim is one product codebase. A Kael application keeps its views, state, layout, components, retained drawing, virtualization, animations, and product logic in Rust, then selects a native or browser host at build time.

That does not mean every operating-system ability exists inside a browser sandbox. Kael keeps the shared application surface large and makes the remaining differences explicit, so portable code is the default and platform branches stay small and intentional.

Start from one entry point

Projects created by kael new arrange target dependencies while keeping one main.rs:

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
kael = { version = "0.4", features = ["runtime_shaders"] }
kael_ui = "0.4"

[target.'cfg(target_arch = "wasm32")'.dependencies]
kael = { version = "0.4", default-features = false, features = ["browser"] }
kael_ui = { version = "0.4", default-features = false, features = ["browser"] }

The application entry point remains normal Kael code:

//! Compiled source for the Getting started and One codebase guides.

use kael_ui::prelude::*;

struct Counter {
    count: i32,
}

impl Render for Counter {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let counter = cx.entity();

        div()
            .size_full()
            .flex()
            .flex_col()
            .items_center()
            .justify_center()
            .gap_4()
            .child(div().text_3xl().child(format!("Count: {}", self.count)))
            .child(
                Button::new("increment", "Increase").on_click(move |_, _, cx| {
                    counter.update(cx, |state, cx| {
                        state.count += 1;
                        cx.notify();
                    });
                }),
            )
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    Application::try_new()?.run(|cx| {
        kael_ui::init(cx);
        install_theme(cx, Theme::dark());

        if let Err(error) = cx.open_window(WindowOptions::default(), |_, cx| {
            cx.new(|_| Counter { count: 0 })
        }) {
            eprintln!("failed to open the application window: {error}");
            cx.quit();
        }
    });
    Ok(())
}

Build either target:

# Native desktop
cargo run

# Local browser build
kael web serve

# Optimized static deployment in dist/web
kael web build

What remains the same

Product layerShared contract
InterfaceElements, components, layout, text, images, SVG, canvas, effects
Application stateEntities, observation, actions, keybindings, async tasks
ScaleVirtual lists and grids, recycling, bounded caches, spatial culling
InteractionPointer, keyboard, wheel, focus, IME, clipboard, accessibility actions
DocumentsByte import/export, recovery snapshots, OOXML, PDF, Markdown
Network and workHTTP, WebSockets, typed background worker requests
Visual behaviorRetained scenes, animation policy, reduced motion, high-DPI layout

The browser sends the same retained Scene to WebGL2. It does not rebuild the interface as HTML and it does not run the application inside a WebView.

What the host adapts

  • Rendering: Metal on macOS, Direct3D 11 on Windows, Vulkan through Blade on Linux, and WebGL2 in the browser.
  • Windows: native OS windows on desktop; independent retained surfaces inside the browser page on the web.
  • Files: native paths and Save As on desktop; byte-backed pickers, drops, and Blob downloads in the browser.
  • Storage: native SQLite/JSON and OS keychains where selected; IndexedDB and bounded browser key/value storage on the web.
  • System services: printing, capture, audio, notifications, sharing, and WebViews use the host implementation and permission model.

Keep product code on the shared APIs. Use native paths, subprocesses, raw window handles, or system credentials only behind a capability decision.

Branch on capability, not platform names

use kael::{CapabilityReport, PlatformFeature};

let report = CapabilityReport::current();

if report.is_supported(PlatformFeature::GlobalHotkeys) {
    enable_global_shortcut_workflow();
} else {
    enable_in_app_shortcut_workflow();
}

This survives more environments than if cfg!(target_os = ...): Linux desktop services vary, browser permissions can be denied, and optional features may not be compiled into a particular build.

Keep platform-owned surfaces deliberate

WebViews are compatibility islands for OAuth, payments, maps, hosted documents, or vendor widgets. They are not the default way to build Kael screens. The retained application continues to own navigation, editors, data surfaces, commands, and long-lived product state.

Use WebViewCapabilityReport before depending on history, cookies, downloads, custom headers, profiles, permissions, or custom protocols. Browser iframe, WKWebView, WebView2, and WebKitGTK security models are not identical.

Test both deliverables

Source parity is not release parity. Before shipping:

  1. Exercise native and optimized Wasm builds.
  2. Test your supported browsers and desktop backends.
  3. Verify high-DPI text, keyboard navigation, IME, screen readers, and reduced motion on each target.
  4. Measure representative data sizes rather than a blank window.
  5. Record the capability report for workflows that depend on the host.

Kael's own browser gates run retained pixels, million-row virtualization, suite-scale workloads, IME, clipboard, context loss, accessibility bounds, workers, audio, WebSockets, capture, and lifecycle behavior. Product tests still need to cover the features and devices the application promises.

Continue with Browser and WebAssembly for the detailed host contract or Suite-scale applications for a maintained one-source workload.

What remains

Kael is broad, but it is not finished. This page separates current gaps from deliberate product boundaries.

CapabilityReport::current() is the live source of truth at runtime. A type or request builder does not prove that every backend implements the operation.

Framework work

These areas need more implementation or wider platform coverage:

AreaCurrent gap
SpellcheckNo native or bundled spelling backend
Realtime networkServer sent event descriptors exist; live transport is not complete
File dragNo outbound promised file drag backend
SharingNo share receiver; outgoing support varies by platform
Location and devicesGeolocation, USB, HID, serial, and Bluetooth backends are absent
Browser install featuresPush, notification actions, and share targets need product service worker code
Input depthSome native pen, touch, and gesture paths need broader coverage
MediaPortable spatial audio is stereo and distance based, not a full room or HRTF engine
Web packagingThe CLI packages source HTML and assets, but has no content hashing or service worker generator
Platform qualityLinux services and hardware coverage still need continued hardening

The public API is pre 1.0. Stabilization, compatibility policy, and wider hardware testing remain release work even where a feature is already usable.

Deliberate boundaries

Some work belongs to applications or focused engines rather than Kael core:

  • Exact Microsoft Office layout, spreadsheet calculation, and slide playback
  • Full PDF authoring and layout parity with specialist products
  • A complete 3D engine with custom shaders, compute, physics, and asset pipelines
  • Browser access to native paths, subprocesses, keychains, global hotkeys, or detached OS windows
  • Product specific collaboration protocols and document semantics

Kael supplies the application runtime, rendering, controls, data foundations, portable files, document bytes, and extension points. A suite or game engine can build its product model above those parts without hiding the remaining work.

How to plan a portable feature

  1. Build the shared view and state first.
  2. Query CapabilityReport at the platform boundary.
  3. Define a useful browser fallback before calling a native service.
  4. Test desktop and browser as separate release targets.
  5. Record unsupported behavior in the product, not only in build scripts.

See One codebase for the structure and Platform APIs for the capability model.

Core Concepts

Application lifecycle

Every Kael app follows this flow:

Application::try_new()? → run() → cx.open_window() → cx.new(|_| View) → render loop
fn main() -> Result<(), Box<dyn std::error::Error>> {
    Application::try_new()?.run(|cx: &mut App| {
        if let Err(error) = cx.open_window(WindowOptions::default(), |_, cx| {
            cx.new(|_| MyView {})
        }) {
            eprintln!("failed to open the application window: {error}");
            cx.quit();
        }
    });
    Ok(())
}

Entity<T> — reactive state containers

An Entity<T> is a handle to a value stored in the framework's arena. When the value changes and you call cx.notify(), any view rendering that entity re-renders automatically.

#![allow(unused)]
fn main() {
struct AppState {
    user: String,
    count: i32,
}

let state: Entity<AppState> = cx.new(|_cx| AppState {
    user: "Alice".into(),
    count: 0,
});

let name = state.read(cx).user.clone();

state.update(cx, |this, cx| {
    this.count += 1;
    cx.notify();
});
}

Entity vs. direct state

If your view struct holds state directly (like struct Counter { count: i32 }), the view IS the entity — cx.new() wraps it in Entity<Counter> automatically. Use separate entities when you need shared state across views:

#![allow(unused)]
fn main() {
struct Sidebar {
    shared: Entity<AppState>,
}

struct Editor {
    shared: Entity<AppState>,
}
}

The Render trait

Any type that implements Render can be displayed in a window:

#![allow(unused)]
fn main() {
impl Render for MyView {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        div().child("Hello")
    }
}
}

Parameters:

  • &mut self — mutable access to your state
  • window: &mut Window — the window being rendered into (for window-level APIs)
  • cx: &mut Context<Self> — entity-scoped context for creating entities, subscribing to events, and notifying changes

Return: Anything implementing IntoElement — a Div, a Button, or any widget.

Context types

ContextWhere you get itWhat it does
AppApplication::try_new()?.run(|cx| { ... })Root context — open windows, set globals
Context<T>impl Render and cx.new() closuresEntity-scoped — notify, observe, subscribe
Windowimpl Render render methodWindow-level — bounds, focus, painting
AsyncApp, AsyncWindowContextConvert a live context before spawning async workFallible access that can safely outlive a window or entity callback
TestAppContextHeadless framework testsDeterministic entity, input, and rendering test access

Getting an entity handle inside render

#![allow(unused)]
fn main() {
impl Render for MyView {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let entity = cx.entity();

        button("click-me")
            .label("Click")
            .on_click(move |_, _, cx| {
                entity.update(cx, |this, cx| {
                    this.handle_click();
                    cx.notify();
                });
            })
    }
}
}

Global state

For app-wide values (theme, user session, config), use the Global trait:

#![allow(unused)]
fn main() {
struct AppConfig {
    dark_mode: bool,
    font_size: f32,
}

impl Global for AppConfig {}

cx.set_global(AppConfig { dark_mode: true, font_size: 14.0 });

cx.read_global::<AppConfig, _>(|config, _| {
    config.dark_mode
});

cx.update_global::<AppConfig, _>(|config, cx| {
    config.dark_mode = false;
});
}

Element composition

Views compose by nesting elements with .child():

#![allow(unused)]
fn main() {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
    div()
        .flex()
        .flex_col()
        .child(self.render_header())
        .child(self.render_content())
        .child(self.render_footer())
}

fn render_header(&self) -> impl IntoElement {
    div().h(px(48.0)).bg(rgb(0x2563eb)).child("Header")
}
}

Conditional rendering

Use .when() for conditional styling or .map() for conditional children:

#![allow(unused)]
fn main() {
div()
    .when(self.is_active, |div| div.bg(rgb(0x2563eb)))
    .when(!self.is_active, |div| div.bg(rgb(0x64748b)))
    .child(if self.show_label { "Active" } else { "Inactive" })
}

Iterating children

Use .children() with an iterator:

#![allow(unused)]
fn main() {
div()
    .flex()
    .flex_col()
    .children(self.items.iter().map(|item| {
        div().px_2().py_1().child(item.name.clone())
    }))
}

Event handling

All events pass (event_data, &mut Window, &mut App):

#![allow(unused)]
fn main() {
div()
    .id("my-element")
    .on_click(|event, window, cx| {
    })
    .on_mouse_down(MouseButton::Left, |event, window, cx| {
    })
    .on_key_down(|event, window, cx| {
    })
}

Widget events use the same pattern:

#![allow(unused)]
fn main() {
text_input("name", self.name.clone())
    .on_change(|new_value, window, cx| {
    })
    .on_submit(|value, window, cx| {
    })
}

Subscriptions and observations

Watch for changes on other entities:

#![allow(unused)]
fn main() {
cx.observe(&other_entity, |this, other, cx| {
    cx.notify();
});

cx.subscribe(&other_entity, |this, _other, event: &MyEvent, cx| {
});
}

State Management

Kael's state model is built on entities: reference-counted, observable pieces of state owned by the application. This chapter covers the full state toolkit — entities, derived state with Computed, globals, and the patterns that keep a large app consistent.

Entities

An Entity<T> is a handle to a value owned by the app. Create one with cx.new, read it with read, and mutate it with update:

#![allow(unused)]
fn main() {
struct Counter {
    count: usize,
}

let counter = cx.new(|_| Counter { count: 0 });

let value = counter.read(cx).count;

counter.update(cx, |counter, cx| {
    counter.count += 1;
    cx.notify();
});
}

cx.notify() is what tells Kael the entity changed: every view observing the entity re-renders, and every computed value depending on it invalidates. Forgetting cx.notify() is the most common cause of a stale UI — if you mutated state and the screen didn't change, check for a missing notify first.

In event handlers inside a view, prefer cx.listener — it hands you &mut Self and the right context without manual entity cloning:

#![allow(unused)]
fn main() {
.on_click(cx.listener(|this, _event, _window, cx| {
    this.count += 1;
    cx.notify();
}))
}

Observing and subscribing

Views and entities react to each other with observe (any notify) and subscribe (typed events from an EventEmitter):

#![allow(unused)]
fn main() {
cx.observe(&other_entity, |this, _other, cx| {
    this.recompute();
    cx.notify();
})
.detach();

cx.subscribe(&input, |this, _input, event: &InputEvent, cx| {
    if matches!(event, InputEvent::Change) {
        this.refilter(cx);
    }
})
.detach();
}

Both return a Subscription that unsubscribes when dropped — hold it in your struct to scope it to the view's lifetime, or .detach() to keep it for the emitter's lifetime.

Derived state with Computed

Computed<T> is Kael's equivalent of a memo: a cached value derived from entities, recomputed only when a dependency actually changes. Reads go through the Tracker argument so dependencies are recorded automatically:

#![allow(unused)]
fn main() {
use kael::computed::Computed;

let filtered = Computed::new(cx, |tracker| {
    let orders = tracker.read(&orders_entity);
    let query = tracker.read(&search_entity).text.to_lowercase();
    orders
        .items
        .iter()
        .filter(|order| order.customer.to_lowercase().contains(&query))
        .cloned()
        .collect::<Vec<_>>()
});

let rows = filtered.read(cx);
}

The closure runs once; the result is cached until any tracked entity notifies. Dependencies re-track on every recompute, so conditional reads work — a branch that stops reading an entity stops depending on it.

A Computed is itself observable: cx.observe(&filtered.entity(), ...) lets a view re-render when the derived value invalidates. Use filtered.get(cx) for a cloned value.

Use Computed whenever you find yourself recomputing derived data inside render — filtering, sorting, aggregating — so the work runs on change, not on every frame.

Globals

App-wide singletons implement the Global marker trait and live on the app:

#![allow(unused)]
fn main() {
struct Settings {
    telemetry: bool,
}
impl Global for Settings {}

cx.set_global(Settings { telemetry: false });
let settings = cx.global::<Settings>();
cx.update_global::<Settings, _>(|settings, _| settings.telemetry = true);
}

React to changes with cx.observe_global::<Settings>(...). The kael_ui theme is a global: read it with Theme::of(cx) (see Theming).

Persistent Settings

Use SettingsStore for typed JSON preferences, workspace defaults, account state, and feature flags that need to survive app restarts:

#![allow(unused)]
fn main() {
use kael::app_runtime::SettingsStore;
use serde::{Deserialize, Serialize};

#[derive(Default, Serialize, Deserialize)]
struct AppSettings {
    theme: String,
    telemetry: bool,
}

let settings_path = app_data_dir.join("settings.json");
let mut settings = SettingsStore::<AppSettings>::builder(&settings_path)
    .load_checked()?;

settings.update(|data| {
    data.theme = "dark".into();
})?;
}

Prefer SettingsStore::new_checked(path) and SettingsStore::builder(path).migration(...).load_checked() for generated app preferences. The checked path rejects empty paths, control-character paths, directory targets, invalid parents, zero-version migrations, and duplicate migration target versions before the app starts reading or atomically writing settings. Raw new(...), load(...), and builder .load() remain available when an app owns filesystem validation.

Undo & Redo

Use UndoRedoManager for editor, canvas, form-builder, and design-tool history:

#![allow(unused)]
fn main() {
use kael::app_runtime::UndoRedoManager;

let mut history = UndoRedoManager::new(100);

history.begin_transaction_checked("move selected layers")?;
history.push(move_layer_change);
history.push(update_bounds_change);
history.end_transaction_checked()?;
}

Prefer begin_transaction_checked(...) and end_transaction_checked() for generated workflows. Checked transactions reject nested begins, missing ends, empty/padded/control-character/overly long descriptions, and expose has_open_transaction() for cleanup and diagnostics. Raw begin_transaction(...) and end_transaction() remain available for hand-written code that wants assert semantics.

Structuring a larger app

  • One entity per unit of independent change. A chat app wants Entity<ChannelList>, Entity<Thread>, Entity<ComposerState> — not one giant struct, which makes every keystroke re-render everything.
  • Derive, don't duplicate. If a value can be computed from other state, use Computed rather than storing a second copy you must keep in sync.
  • Events for actions, observation for state. Emit typed events for things that happen (message sent); observe entities for things that are (current draft).
  • Wire async through weak handles. See Async & Data Fetching for the spawn/weak-update pattern and the Loadable/QueryState helpers.

A worked example lives in the dashboard template (templates/dashboard): the search input subscribes to InputEvent::Change and refilters the orders table through DataTable::set_data.

Async & Data Fetching

Kael apps stay responsive by doing work off the render path: futures run on the foreground or background executor, and results land back in entities through weak handles. kael_ui layers Loadable and QueryState on top so the common fetch-render lifecycle needs no boilerplate.

Tasks and executors

cx.spawn runs a future on the main thread with access to an async context; cx.background_spawn runs CPU-bound or blocking work on the thread pool.

#![allow(unused)]
fn main() {
cx.spawn(async move |this, cx| {
    let data = cx
        .background_executor()
        .spawn(async move { expensive_parse(bytes) })
        .await;

    this.update(cx, |this, cx| {
        this.data = Some(data);
        cx.notify();
    })
    .ok();
})
.detach();
}

The closure receives a WeakEntity<Self> — the entity may be dropped while the future runs, which is why every update through it returns a Result. Dropping a Task cancels it; call .detach() to let it run to completion, or store it in your struct so navigating away cancels in-flight work.

Background Jobs

Use JobScheduler when work needs durable status, progress, retry metadata, dependencies, cancellation, or a worker-pool handoff:

#![allow(unused)]
fn main() {
use kael::background_jobs::{JobDescriptor, JobPriority, JobScheduler, RetryPolicy};

let scheduler = JobScheduler::new().with_max_concurrent(2);
let descriptor = JobDescriptor::new("export/video")
    .with_priority(JobPriority::High)
    .with_retry_policy(RetryPolicy {
        max_retries: 2,
        delay_ms: 500,
        backoff_multiplier: 2.0,
    });

let job_id = scheduler.schedule_with_descriptor_checked(export_job, descriptor)?;
}

Prefer schedule_checked(...) or schedule_with_descriptor_checked(...) for generated background work. Checked scheduling rejects empty, padded, control-character, overly long, or non-portable job IDs, descriptor/job ID mismatches, self-dependencies, duplicate or invalid dependency IDs, and invalid retry policies before the queue state is mutated. Raw schedule(...) and schedule_with_descriptor(...) remain available when an app owns validation.

Loadable: the four states of remote data

kael_ui::query::Loadable<T> models the lifecycle every fetched value goes through:

#![allow(unused)]
fn main() {
use kael_ui::prelude::Loadable;

match &self.orders {
    Loadable::Idle => div().child("Press fetch"),
    Loadable::Loading => Skeleton::new("orders-skeleton").into_any_element(),
    Loadable::Loaded(orders) => render_orders(orders),
    Loadable::Error(message) => Banner::error(message.clone()),
}
}

QueryState: fetch lifecycle without the footguns

QueryState<T> owns a Loadable<T> and manages the transitions: it sets Loading, spawns the fetch, writes the result back through a weak handle, and drops stale responses when a newer fetch started (a generation counter — no flash of old results when the user types fast). It supports debounce and refetch.

#![allow(unused)]
fn main() {
use kael_ui::query::QueryState;

struct OrdersView {
    orders: QueryState<Vec<Order>>,
}

self.orders.run(cx, |cx| async move {
    fetch_orders(cx).await.map_err(|error| error.to_string().into())
});
}

For request dedupe across views, QueryCache keys results by string with a TTL.

The Astryx showcase composes query loading, data, refetch, and error states in one application.

Rules of thumb

  • Never block the main thread: decode, parse, and diff on the background executor, then apply to entities on the foreground.
  • Treat WeakEntity::update failures as cancellation, not errors — the view is gone; .ok() is the idiomatic acknowledgment.
  • Hold the Task when navigation should cancel the request; detach when the result matters regardless.
  • Set state to Loading before awaiting so the UI reflects the fetch immediately; QueryState::run does this for you.

Layout & Styling

Kael uses GPU-accelerated flexbox (powered by Taffy) with a Tailwind-inspired API. Every style is a method call on a Div.

Flexbox layout

#![allow(unused)]
fn main() {
div().flex().flex_row().gap_2()
    .child(div().child("Left"))
    .child(div().child("Right"))

div().flex().flex_col().gap_4()
    .child(div().child("Top"))
    .child(div().child("Bottom"))
}

Alignment

#![allow(unused)]
fn main() {
div().flex()
    .items_center()
    .justify_center()
    .justify_between()
    .items_start()
    .items_end()
}

Flex sizing

#![allow(unused)]
fn main() {
div().flex_1()
div().flex_grow()
div().flex_shrink_0()
div().flex_none()
}

Grid layout

Switch a container to Kael's native grid layout with .grid(), define tracks with .grid_cols(n) / .grid_rows(n), and place children with .col_span(n) / .row_span(n) (or .col_span_full() / .row_span_full()). .gap_*() sets the gutters:

#![allow(unused)]
fn main() {
div()
    .grid()
    .grid_cols(5)
    .grid_rows(5)
    .gap_1()
    .child(div().row_span(1).col_span_full().child("Header"))
    .child(div().col_span(1).row_span(3).child("Sidebar"))
    .child(div().col_span(3).row_span(3).child("Content"))
    .child(div().col_span(1).row_span(3).child("Aside"))
    .child(div().row_span(1).col_span_full().child("Footer"))
}

See the Astryx showcase's layout section for a complete application shell.

Sizing

#![allow(unused)]
fn main() {
div().w(px(200.0)).h(px(100.0))

div().w_full()
div().h_full()
div().size_full()

div().size_8()
div().w_12()
div().h_6()

div().min_w(px(200.0)).max_w(px(600.0))
}

Spacing

#![allow(unused)]
fn main() {
div().p_4()
div().px_3()
div().py_2()
div().pt_1()
div().pl(px(20.0))

div().m_4()
div().mx_auto()
div().mt_2()

div().flex().gap_2()
div().flex().gap_4()
}

Colors

#![allow(unused)]
fn main() {
div().bg(rgb(0x1E1E1E))
div().text_color(rgb(0xFFFFFF))
div().border_color(rgb(0x3C3C3C))

div().bg(rgba(0x00000080))

div().bg(kael::red())
div().bg(kael::blue())
div().bg(kael::white())
div().bg(kael::black())

use kael::hsla;
div().bg(hsla(210.0 / 360.0, 1.0, 0.5, 1.0))
}

Borders

#![allow(unused)]
fn main() {
div().border_1()
div().border_2()
div().border_t_1()
div().border_b_1()
div().border_l_1()
div().border_r_1()
div().border_color(rgb(0x3C3C3C))
div().border_dashed()
}

Corners

#![allow(unused)]
fn main() {
div().rounded_sm()
div().rounded_md()
div().rounded_lg()
div().rounded_full()
div().rounded(px(8.0))
}

By default, rounded corners use continuous (squircle) rounding to match SwiftUI's RoundedRectangle shape on macOS. Use .circular_corners() to opt into the legacy pure quarter-circle look:

#![allow(unused)]
fn main() {
div().rounded(px(8.0)).circular_corners()
}

Shadows

#![allow(unused)]
fn main() {
div().shadow_sm()
div().shadow_md()
div().shadow_lg()
div().shadow_xl()
}

Typography

#![allow(unused)]
fn main() {
div()
    .text_xs()
    .text_sm()
    .text_base()
    .text_lg()
    .text_xl()
    .text_2xl()
    .text_3xl()

div().font_weight(FontWeight::BOLD)
div().font_family(".SystemUIFont")
}

Overflow and scrolling

Control how content behaves when it exceeds the element bounds:

#![allow(unused)]
fn main() {
div().overflow_hidden()
div().overflow_x_scroll()
div().overflow_y_scroll()
div().overflow_y_auto()
    .id("scroll-container")
}

When using overflow_y_scroll() or overflow_y_auto() with a ScrollHandle, Kael automatically renders a macOS-style scrollbar thumb when content overflows. No extra widget is needed:

#![allow(unused)]
fn main() {
let scroll_handle = ScrollHandle::new();

div()
    .id("my-scrollable")
    .overflow_y_scroll()
    .track_scroll(&scroll_handle)
    .child(long_content)
}

The auto-scrollbar appears only when content exceeds the viewport and tracks the scroll position automatically. To keep scrollbars visible at all times (instead of auto-hiding after idle):

#![allow(unused)]
fn main() {
let scroll_handle = ScrollHandle::new().always_show_scrollbars();
}

For custom scrollbar styling, use the explicit scroll_bar() widget instead (see Lists & Data).

Positioning

#![allow(unused)]
fn main() {
div().relative()
    .child(
        div().absolute()
            .top(px(10.0))
            .right(px(10.0))
            .child("Badge")
    )
}

Opacity

#![allow(unused)]
fn main() {
div().opacity(0.5)
}

Cursor

#![allow(unused)]
fn main() {
div().cursor_pointer()
div().cursor_default()
}

Conditional styling with .when()

#![allow(unused)]
fn main() {
div()
    .when(self.is_selected, |this| {
        this.bg(rgb(0x2563eb)).text_color(rgb(0xffffff))
    })
    .when(!self.is_selected, |this| {
        this.bg(rgb(0xffffff)).text_color(rgb(0x000000))
    })
}

Authoring Components

Most of Kael's UI is built by composing div() and the widgets in kael_ui. When a piece of UI repeats, or earns a name, you promote it to a component. This guide walks the ladder from the simplest approach to the most powerful — stop climbing the moment your needs are met.

Rung 1: compose in Render

The plainest reuse is a helper method that returns impl IntoElement. No new types, no traits — just split a large render into named pieces:

#![allow(unused)]
fn main() {
impl Render for Dashboard {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .flex()
            .flex_col()
            .child(self.header())
            .child(self.body(cx))
    }
}

impl Dashboard {
    fn header(&self) -> impl IntoElement {
        div().h(px(48.0)).child(self.title.clone())
    }
}
}

Reach for a real component only when the markup needs to live outside this view — used by more than one screen, or configured through its own builder.

Rung 2: a stateless component with RenderOnce

A RenderOnce component is a recipe: build it, configure it through chained methods, render it once. Derive IntoElement so it drops into any .child() like a built-in widget.

Here is a complete Badge — a small colored pill — modeled on the shape every simple kael_ui component shares:

#![allow(unused)]
fn main() {
use crate::theme::Theme;
use kael::{prelude::FluentBuilder as _, *};

#[derive(IntoElement)]
pub struct Badge {
    label: SharedString,
    subtle: bool,
    style: StyleRefinement,
}

impl Badge {
    pub fn new(label: impl Into<SharedString>) -> Self {
        Self {
            label: label.into(),
            subtle: false,
            style: StyleRefinement::default(),
        }
    }

    pub fn subtle(mut self, subtle: bool) -> Self {
        self.subtle = subtle;
        self
    }
}

impl Styled for Badge {
    fn style(&mut self) -> &mut StyleRefinement {
        &mut self.style
    }
}

impl RenderOnce for Badge {
    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
        let tokens = &Theme::of(cx).tokens;
        let user_style = self.style;

        let (bg, fg) = if self.subtle {
            (tokens.muted, tokens.muted_foreground)
        } else {
            (tokens.primary, tokens.primary_foreground)
        };

        div()
            .px(px(8.0))
            .py(px(2.0))
            .rounded(tokens.radius_sm)
            .bg(bg)
            .text_color(fg)
            .text_xs()
            .font_family(tokens.font_family.clone())
            .child(self.label)
            .map(|this| {
                let mut div = this;
                div.style().refine(&user_style);
                div
            })
    }
}
}

Use it anywhere:

#![allow(unused)]
fn main() {
div().child(Badge::new("New")).child(Badge::new("Beta").subtle(true))
}

Four conventions are doing the work here, and every kael_ui component follows them:

  1. Builder methods take mut self and return Self so calls chain.
  2. #[derive(IntoElement)] + impl RenderOnce turns the struct into something .child() accepts.
  3. Read the theme with Theme::of(cx) — never hardcode colors. Pull tokens once, then style from tokens.primary, tokens.radius_sm, and friends so the component tracks the active theme and any live theme switch.
  4. Carry a StyleRefinement and apply it last via impl Styled plus the closing .map(...), so callers can override your defaults with .bg(...), .w(...), and the rest.

RenderOnce receives cx: &mut App (not Context<Self>): a recipe has no persistent identity, so it cannot notify itself. Wire interactivity through callbacks (on_click, on_change) that update an entity the caller owns — see how kael_ui's controls thread an Entity through their handlers.

Rung 3: a stateful component with Render

When a component owns state that changes over its lifetime — an open/closed flag, a scroll position, a cached value — make it an Entity with impl Render. Now render takes &mut self and Context<Self>, so it can mutate itself and call cx.notify():

#![allow(unused)]
fn main() {
struct Disclosure {
    open: bool,
    title: SharedString,
}

impl Render for Disclosure {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let entity = cx.entity();

        div()
            .id("disclosure")
            .transition(Theme::of(cx).tokens.transition_fast)
            .child(self.title.clone())
            .on_click(move |_, _, cx| {
                entity.update(cx, |this, cx| {
                    this.open = !this.open;
                    cx.notify();
                });
            })
            .when(self.open, |this| this.child("…details…"))
    }
}
}

The caller creates it with cx.new(|_| Disclosure { open: false, title: "Advanced".into() }) and holds the Entity<Disclosure>. See State Management for the full story on entities, observation, and shared state.

Transition conventions

Whenever a component restyles on hover, active, or a state change, ease it. Add .transition(duration) to the element — it must have a stable .id(...), since unkeyed elements snap. Match kael_ui's convention:

  • tokens.transition_fast for color-only changes — background, text, border tint.
  • tokens.transition_base for changes that move or resize — shadow steps, a hover lift, scale.
#![allow(unused)]
fn main() {
div()
    .id("row")
    .transition(Theme::of(cx).tokens.transition_fast)
    .hover(|style| style.bg(Theme::of(cx).tokens.accent))
}

Animations covers the full set of interpolated properties, explicit with_animation timelines, and springs.

Rung 4: custom drawing with canvas

For visuals that styled divs cannot express — waveforms, charts, custom paint — drop to the canvas. canvas(prepaint, paint) and canvas_with_prepaint(prepaint, paint) hand you the element's Bounds and the Window, and you paint quads, paths, and shadows directly. The prepaint closure runs first and can return data the paint closure reuses, keeping per-frame work cheap.

For high-volume immediate-mode drawing, canvas(size, draw) exposes DrawContext::reserve_commands, fill_rects, and fill_circles. Reserve known work before mixed command streams, batch repeated rectangles, and prefer the rounded-quad circle path for particles or graph nodes that do not require vector tessellation.

The Waveform component in crates/kael_ui/src/components/waveform.rs is the pattern to copy: it builds a paint_data struct in prepaint, then a free paint_waveform(bounds, &data, window) function does the drawing. Wrap the canvas in a positioned div() so layout still owns sizing:

#![allow(unused)]
fn main() {
div()
    .relative()
    .h(px(48.0))
    .child(
        canvas_with_prepaint(
            move |_bounds, _window, _cx| paint_data,
            move |bounds, data, window, _cx| paint_waveform(bounds, &data, window),
        )
        .absolute()
        .inset_0()
        .size_full(),
    )
}

You rarely need the raw Element trait

Beneath everything is the Element trait with its request_layout / prepaint / paint lifecycle. RenderOnce, Render, and canvas are all built on top of it, and they cover the overwhelming majority of components. Implement Element by hand only when you need to control layout participation itself — a custom layout container, or an element that measures children and positions them manually. If you are reaching for it to draw or to hold state, one of the rungs above is the better fit.

Component Library (kael_ui)

Kael ships a complete, shadcn-inspired component library: kael_ui. It provides 100+ polished, accessible components so you can build rich desktop applications with Kael alone — no external component library required.

kael_ui is the continuation of adabraka-ui, now developed inside the Kael repository at crates/kael_ui.

Installation

[dependencies]
kael = "0.4"
kael_ui = "0.4"

Setup

One import gives you everything — the components plus the Kael essentials (div, px, Render, Application, …). You do not need a separate use kael::*;, and mixing the two globs is discouraged because the names collide:

use kael_ui::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    Application::try_new()?.run(|cx: &mut App| {
        kael_ui::init(cx);
        install_theme(cx, Theme::dark());

        if let Err(error) = cx.open_window(WindowOptions::default(), |_, cx| {
            cx.new(|_| MyApp)
        }) {
            eprintln!("failed to open the application window: {error}");
            cx.quit();
        }
    });
    Ok(())
}

kael_ui::init(cx) registers the bundled Inter and JetBrains Mono fonts, sets up keybindings for interactive components (inputs, selects, the editor, sidebars, popovers, sheets, dialogs), and initializes the HTTP client used for remote image loading.

Using the theme

install_theme stores the active Theme in the app's global state, so the recommended way to read it is Theme::get(cx) (or the alias Theme::of(cx)), which borrows the theme out of cx with no per-render clone:

impl Render for MyApp {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let theme = Theme::of(cx);
        div()
            .bg(theme.tokens.background)
            .text_color(theme.tokens.foreground)
            .child(
                Button::new("save", "Save")
                    .variant(ButtonVariant::Default)
                    .on_click(|_, _, _| println!("Saved!")),
            )
    }
}

use_theme() is still available and returns an owned Theme; it is the legacy path (it clones the whole theme on every call and does not take a cx). Prefer Theme::get(cx) / Theme::of(cx) in new code.

Tokens follow shadcn/ui naming: background/foreground, primary, secondary, muted, accent, destructive, border, card, and so on, each with light and dark variants.

Custom themes and live switching

Eighteen presets ship in-tree (Theme::dark(), Theme::light(), Theme::tokyo_night(), Theme::catppuccin_mocha(), Theme::nord(), …), and you can brand your app with Theme::custom: start from any preset's tokens and override only what you need with struct-update syntax.

let brand = Theme::custom(ThemeTokens {
    primary: hsla(262.0 / 360.0, 0.83, 0.58, 1.0),
    primary_foreground: hsla(0.0, 0.0, 1.0, 1.0),
    radius_md: px(10.0),
    ..ThemeTokens::dark()
});
install_theme(cx, brand);

install_theme can be called again at any time — it refreshes every open window, so components re-read the new tokens immediately. Wiring a theme picker is just a button:

Button::new("theme-light", "Light").on_click(cx.listener(|_, _, _, cx| {
    install_theme(cx, Theme::light());
    cx.notify();
}))

Customizing individual components

Every component implements Kael's Styled trait, so the entire Tailwind-like styling API works directly on it — this is the className of kael_ui. User styles are applied last and override the component's defaults:

Button::new("cta", "Get started")
    .rounded(px(999.0))          // pill shape
    .px(px(28.0))                // wider padding
    .bg(rgb(0x8b5cf6))           // one-off brand color
    .shadow_lg()

Card::new()
    .content(body("Hello"))
    .w(px(360.0))
    .border_2()
    .border_color(rgb(0x10b981))

Use the theme for app-wide identity and Styled overrides for one-off adjustments. The repository-only Astryx showcase composes both approaches in one application.

What's included

ModuleComponents
componentsButton, IconButton, Input, Textarea, SearchInput, NumberInput, OtpInput, TagInput, MentionInput, HotkeyInput, Checkbox, Radio, Toggle, Switch, Slider, RangeSlider, Select, Combobox, Dropdown, DatePicker, TimePicker, Calendar, ColorPicker, Rating, FileUpload, Avatar, AvatarGroup, Progress, Spinner, Skeleton, Stepper, Pagination, Carousel, Timeline, QrCode, CopyButton, InlineEdit, code Editor with tree-sitter syntax highlighting, audio/video players, and many more
displayTable, DataTable, DataGrid, Card, Badge, Accordion, RichText, Markdown and HTML rendering (feature-gated)
navigationSidebar, Menu, AppMenu, Tabs, Breadcrumbs, Toolbar, StatusBar, Tree, FileTree, VirtualList
overlaysDialog, AlertDialog, ConfirmDialog, Sheet, BottomSheet, Popover, PopoverMenu, HoverCard, ContextMenu, Toast, Tooltip, CommandPalette
chartsLineChart, AreaChart, BarChart, PieChart, DonutChart, RadarChart, Gauge, Heatmap, Treemap, Sparkline
layoutVStack, HStack, Grid, ScrollContainer, responsive breakpoint helpers
animationsEasing presets, springs, transitions, animated presence/state, shimmer, confetti, and other motion effects

For native desktop code editors, markdown editors, log viewers, SQL consoles, and prompt builders, prefer the native Editor before embedding Monaco, CodeMirror, or a WebView textarea. Position::to_text(), Selection::to_text(), FoldRange::to_text(), EditorDiagnostic::to_text(), EditorState::to_text(), and Editor::to_text() expose language, line/content byte counts, cursor and selection geometry, modified/file-path presence, undo/redo depth, syntax and highlight readiness, search counts/options, fold state, readonly mode, diagnostic counts, and visual override coverage without logging document text, file paths, selected text, search terms, diagnostic messages, or style callback internals.

For native desktop dashboards, admin tools, file managers, and data-heavy workspaces, prefer native Table, DataTable, and DataGrid components before reaching for browser tables. ColumnDef::to_text(), DataTableState::to_text(), DataTable::to_text(), RowAction::to_text(), GridColumnDef::to_text(), DataGridState::to_text(), and DataGrid::to_text() expose column/row counts, virtual backing, cached rows, sort and selection state, editable columns, active edit buffers, search presence, edit/row-action handlers, and load-more/fetch-page wiring without logging headers, row values, ids, labels, queries, edit text, dimensions, or callback internals.

For native desktop media players, timelines, podcast tools, video review surfaces, and lightweight editors, prefer native VideoPlayer, AudioPlayer, and Waveform before embedding a browser player. VideoPlayer::to_text(), VideoPlayerState::to_text(), VideoCaptionStyle::to_text(), AudioPlayer::to_text(), AudioPlayerState::to_text(), and Waveform::to_text() expose source kind, route, player size, controls/captions/poster/source/title presence, progress and volume buckets, handler counts, and waveform sample shape without logging media URLs, file paths, titles, caption text, exact seek times, volume or rate values, waveform amplitudes, or colors.

For native desktop dialogs, sheets, custom menus, context menus, command palettes, and omniboxes, prefer the native Dialog, Sheet, BottomSheet, Menu, ContextMenu, MenuBar, and CommandPalette stacks instead of hosted browser overlays. Their to_text() helpers expose size/purpose/dismissal policy, header/content/footer presence, item/result counts, nesting, disabled state, shortcut coverage, query presence/length, selection state, and handler coverage without logging ids, labels, titles, descriptions, categories, shortcut strings, user queries, coordinates, dimensions, child contents, or callback internals.

Icons

Components render Lucide icons by name. kael_ui bundles the compact set used by its built-in components, so published-crate consumers do not need to copy framework assets into their application. An application asset source is checked first, preserving branded overrides.

The complete 1,600+ SVG catalog remains repository-only under crates/kael_ui/assets/icons for discovery and the Astryx showcase. Point the resolver at your own icon directory to replace the bundled set:

kael_ui::set_icon_base_path("assets/icons");

Feature flags

FeatureDefaultEnables
httpyesRemote image loading (Avatar, image components)
markdownnodisplay::markdown rendering
html-rendernodisplay::html rendering
audionoAudioPlayer playback via rodio
image-avif, image-exrnoOpt-in AVIF (libdav1d) and OpenEXR decoding
editor-languagesnoTree-sitter grammars for 20+ languages in the editor

Showcase

The repository keeps one comprehensive, sectioned showcase instead of a large collection of small examples:

cargo run -p kael_ui --example astryx_showcase \
  --features "markdown html-render audio media editor-languages"

The showcase is not part of the kael_ui crate package.

Template apps

Three complete starter applications live in templates/ — copy one as the skeleton of your own app:

cargo run -p dashboard-app    # analytics: sidebar, stat cards, charts, data table
cargo run -p messaging-app    # chat: conversation list, message bubbles, composer
cargo run -p workspace-app    # IDE shell: file tree, syntax-highlighted editor, status bar

Form Controls

Every form control follows the same pattern:

  1. Create with widget_name(id, value, ...)
  2. Chain builder methods for configuration
  3. Add .on_change() for state updates
  4. Optionally add .render_with() for custom visuals

All controls support keyboard navigation and accessibility out of the box.


Button

A focusable, clickable element with label support.

#![allow(unused)]
fn main() {
use kael::button;

button("save-btn")
    .label("Save File")
    .on_click({
        let entity = entity.clone();
        move |_event, _window, cx| {
            entity.update(cx, |this, cx| {
                this.save();
                cx.notify();
            });
        }
    })
}

Builder methods:

MethodDescription
.label(text)Display text
.disabled()Disable interaction
.on_click(handler)Click handler (|event, window, cx| { ... })
.render_with(renderer)Custom rendering with ButtonRenderState

ButtonRenderState fields: label: Option<SharedString>, focused: bool, disabled: bool

Use state.to_text(), has_label(), and label_len_bytes() in custom renderers when logging or testing generated button chrome. The summary exposes focus, disabled state, label presence, and label byte length without logging the button text.


TextInput

Full-featured text field with selection, clipboard, undo/redo, and password masking.

#![allow(unused)]
fn main() {
use kael::text_input;

text_input("project_name", self.name.clone())
    .placeholder("Enter project name")
    .on_change({
        let entity = entity.clone();
        move |value, _window, cx| {
            entity.update(cx, |this, cx| {
                this.name = value;
                cx.notify();
            });
        }
    })
}

Builder methods:

MethodDescription
.placeholder(text)Placeholder text when empty
.multi_line()Enable multiline editing
.max_lines(n)Limit visible height
.password()Mask input characters
.mask(impl InputMask)Custom input normalization
.on_change(handler)Text change handler (|value: SharedString, window, cx|)
.on_submit(handler)Enter key handler (|value: SharedString, window, cx|)
.render_with(renderer)Custom rendering with TextInputRenderState

TextInputRenderState fields: value, display_text, placeholder, showing_placeholder, focused, hovered, multi_line, outer_bounds, field_bounds, text_bounds, line_height, lines, selection_bounds, cursor_bounds

Custom rendering helpers on state: state.paint_selection(color, window), state.paint_text(window, cx), state.paint_cursor(color, window)

For custom renderers, use state.to_text(), value_len_bytes(), display_text_len_bytes(), placeholder_len_bytes(), has_placeholder(), is_empty(), is_masked_display(), line_count(), selection_rect_count(), has_selection(), and has_cursor() for content-safe diagnostics. These summaries describe focus, placeholder, multiline, selection, caret, masking, and line shape without logging the field value, placeholder text, displayed password mask contents, selected text, or geometry coordinates.


RichText

Native formatted text for previews, feeds, mentions, links, inline chips, and read-only editor surfaces:

#![allow(unused)]
fn main() {
use kael::{rich_text, HighlightStyle};

let body = rich_text()
    .selectable()
    .text("Welcome ")
    .styled("builder", HighlightStyle::default())
    .link("docs", "https://example.com/docs", |_, _| {})
    .mention("@sam", "user-42", |_, _| {})
    .code("cargo run")
    .build();

tracing::info!(summary = body.to_text(), "rich text");
}

Builder methods:

MethodDescription
.text(text)Plain text segment
.styled(text, HighlightStyle)Highlighted text segment
.link(text, target, handler)Clickable link entity
.mention(text, payload, handler)Clickable mention entity
.hashtag(text, payload, handler)Clickable hashtag entity
.code(text)Inline code-styled segment
.inline_element(element)Inline child element
.inline_element_with_baseline(element, px)Inline child with explicit baseline
.selectable()Enable native text selection
.track_layout(layout)Track selection and geometry through RichTextLayout
.selection_color(color)Override selection highlight color

Use to_text(), segment_count(), text_segment_count(), text_len_bytes(), inline_element_count(), inline_baseline_count(), highlighted_segment_count(), code_segment_count(), entity_count(), link_count(), mention_count(), hashtag_count(), click_handler_count(), is_selectable(), has_selection_color(), and has_element_id() for content-safe agent summaries before render. These summaries do not log text, URLs, mentions, hashtags, code contents, or entity payloads.


Checkbox

Three-state checkbox (checked, unchecked, indeterminate) with undo/redo.

#![allow(unused)]
fn main() {
use kael::checkbox;

checkbox("notifications", self.enabled)
    .label("Enable notifications")
    .on_change({
        let entity = entity.clone();
        move |checked, _window, cx| {
            entity.update(cx, |this, cx| {
                this.enabled = *checked;
                cx.notify();
            });
        }
    })
}

Builder methods:

MethodDescription
.label(text)Label text
.indeterminate(bool)Show indeterminate state
.disabled()Disable interaction
.on_change(handler)State change (|&bool, window, cx|)
.render_with(renderer)Custom rendering with CheckboxRenderState

CheckboxRenderState fields: checked, indeterminate, label, focused, disabled

Use state.to_text(), has_label(), and label_len_bytes() in custom renderers. The summary reports checked, indeterminate, focus, disabled, and label-shape state without logging the label text.


Toggle

Boolean on/off switch with undo/redo.

#![allow(unused)]
fn main() {
use kael::toggle;

toggle("dark_mode", self.dark_mode)
    .label("Dark mode")
    .on_change({
        let entity = entity.clone();
        move |on, _window, cx| {
            entity.update(cx, |this, cx| {
                this.dark_mode = *on;
                cx.notify();
            });
        }
    })
}

Builder methods:

MethodDescription
.label(text)Label text
.disabled()Disable interaction
.on_change(handler)State change (|&bool, window, cx|)
.render_with(renderer)Custom rendering with ToggleRenderState

ToggleRenderState fields: on, label, focused, disabled

Use state.to_text(), has_label(), and label_len_bytes() in custom renderers. The summary reports on/off, focus, disabled, and label-shape state without logging the label text.


RadioGroup

Mutually exclusive option selection with generic value types.

#![allow(unused)]
fn main() {
use kael::radio_group;

#[derive(Clone, Copy, PartialEq, Eq)]
enum Theme { Light, Dark, System }

radio_group("theme", self.theme, [
    (Theme::Light, "Light"),
    (Theme::Dark, "Dark"),
    (Theme::System, "System"),
])
.on_change({
    let entity = entity.clone();
    move |value, _window, cx| {
        entity.update(cx, |this, cx| {
            this.theme = *value;
            cx.notify();
        });
    }
})
}

Builder methods:

MethodDescription
.on_change(handler)Selection change (|&T, window, cx|)
.render_with(renderer)Custom rendering per option with RadioItemRenderState<T>

RadioItemRenderState fields: value, label, index, option_count, selected, focused, disabled

Use state.to_text(), label_len_bytes(), is_first(), and is_last() in custom renderers. The summary reports option position, count, selection, focus, disabled, and label byte length without logging option values or label text.


Slider

Continuous or discrete value control with drag support.

#![allow(unused)]
fn main() {
use kael::slider;

slider("volume", self.volume)
    .min(0.0)
    .max(100.0)
    .step(5.0)
    .on_change({
        let entity = entity.clone();
        move |value, _window, cx| {
            entity.update(cx, |this, cx| {
                this.volume = *value;
                cx.notify();
            });
        }
    })
}

Builder methods:

MethodDescription
.min(f64)Minimum value (default: 0.0)
.max(f64)Maximum value (default: 100.0)
.step(f64)Keyboard increment (default: 1.0)
.discrete()Snap to step values
.vertical()Vertical orientation
.disabled()Disable interaction
.on_change(handler)Value change (|&f64, window, cx|)
.render_with(renderer)Custom rendering with SliderRenderState

SliderRenderState fields: value, min, max, percentage, dragging, focused, disabled

Use state.to_text(), position_class(), is_at_min(), and is_at_max() in custom renderers. The summary reports coarse position, edge state, dragging, focus, and disabled state without logging exact values, bounds, or fractions.


Progress

Determinate or indeterminate progress indicator with custom paint support.

#![allow(unused)]
fn main() {
use kael::progress;

progress("download", self.downloaded_bytes as f64)
    .max(self.total_bytes as f64)
}

Builder methods:

MethodDescription
.max(f64)Maximum value (default: 1.0)
.indeterminate()Show busy progress without a numeric value
.render_with(renderer)Custom painting with ProgressRenderState

ProgressRenderState fields: value, max, percentage, indeterminate

Use state.to_text(), is_determinate(), and completion_class() in custom renderers or generated task UIs. The summary reports determinate/indeterminate state and a coarse completion class without logging exact values, maximums, or fractions.


Tabs

Controlled tab list with caller-owned panels and customizable tab triggers.

#![allow(unused)]
fn main() {
use kael::{TabItem, tabs};

tabs("settings", self.section, [
    TabItem::new(Section::General, "General", general_panel()),
    TabItem::new(Section::Billing, "Billing", billing_panel()),
])
.on_change({
    let entity = entity.clone();
    move |section, _window, cx| {
        entity.update(cx, |this, cx| {
            this.section = *section;
            cx.notify();
        });
    }
})
}

Builder methods:

MethodDescription
.on_change(handler)Selection change (|&T, window, cx|)
.render_tabs_with(renderer)Custom tab trigger rendering with TabRenderState<T>

TabRenderState fields: value, label, index, tab_count, selected, focused

Use state.to_text(), label_len_bytes(), is_first(), and is_last() in custom tab renderers. The summary reports tab position, count, selection, focus, and label byte length without logging tab values or label text.


Disclosure

Controlled expandable section with caller-owned trigger visuals and panel content.

#![allow(unused)]
fn main() {
use kael::disclosure;

disclosure("advanced", self.advanced_open)
    .label("Advanced")
    .panel(advanced_panel())
    .on_change({
        let entity = entity.clone();
        move |open, _window, cx| {
            entity.update(cx, |this, cx| {
                this.advanced_open = *open;
                cx.notify();
            });
        }
    })
}

Builder methods:

MethodDescription
.label(text)Trigger label text
.panel(element)Content shown while open
.on_change(handler)Open-state change (|&bool, window, cx|)
.render_with(renderer)Custom trigger rendering with DisclosureRenderState

DisclosureRenderState fields: open, label, focused

Use state.to_text(), has_label(), and label_len_bytes() in custom trigger renderers. The summary reports open, focus, label presence, and label byte length without logging trigger label text.


Controlled dialog overlay with caller-owned content, backdrop, and dismissal policy.

#![allow(unused)]
fn main() {
use kael::modal;

modal("confirm-delete", self.confirming_delete)
    .label("Confirm delete")
    .dismiss_on_escape(true)
    .dismiss_on_click_outside(true)
    .render_with(|state, _window, _cx| {
        tracing::info!(summary = state.to_text(), "modal");
        confirm_delete_panel().into_any_element()
    })
    .on_change({
        let entity = entity.clone();
        move |open, _window, cx| {
            entity.update(cx, |this, cx| {
                this.confirming_delete = *open;
                cx.notify();
            });
        }
    })
}

Builder methods:

MethodDescription
.label(text)Dialog accessibility label
.backdrop(color)Backdrop color behind the dialog
.dismiss_on_click_outside(bool)Request dismissal on outside click
.dismiss_on_escape(bool)Request dismissal on Escape
.on_change(handler)Open-state change (|&bool, window, cx|)
.render_with(renderer)Custom dialog rendering with ModalRenderState

ModalRenderState fields: open, label, focused, dismiss_on_click_outside, dismiss_on_escape

Use state.to_text(), has_label(), label_len_bytes(), and dismissal_mode() in custom dialog renderers. The summary reports open, focus, label shape, and dismissal policy without logging dialog label text.


Popover

Controlled anchored overlay for menus, pickers, help panels, and compact inspectors.

#![allow(unused)]
fn main() {
use kael::popover;

popover("help", self.help_open)
    .render_anchor_with(|state, _window, _cx| {
        tracing::info!(summary = state.to_text(), "popover anchor");
        help_button(state.open).into_any_element()
    })
    .render_popup_with(|state, _window, _cx| {
        tracing::info!(summary = state.to_text(), "popover popup");
        help_panel(state.width).into_any_element()
    })
    .on_open_change({
        let entity = entity.clone();
        move |open, _window, cx| {
            entity.update(cx, |this, cx| {
                this.help_open = *open;
                cx.notify();
            });
        }
    })
}

Builder methods:

MethodDescription
.on_open_change(handler)Open-state change (|&bool, window, cx|)
.render_anchor_with(renderer)Custom anchor rendering with PopoverAnchorRenderState
.render_popup_with(renderer)Custom popup rendering with PopoverPopupRenderState
.dismiss_on_click_outside(bool)Request dismissal on outside click
.dismiss_on_escape(bool)Request dismissal on Escape
.offset(point)Offset popup relative to anchor

PopoverAnchorRenderState fields: open, dismiss_on_click_outside, dismiss_on_escape

PopoverPopupRenderState fields: open, width, anchor_bounds, focused, dismiss_on_click_outside, dismiss_on_escape

Use anchor to_text() plus popup to_text(), has_anchor_bounds(), width_class(), and dismissal_mode() in custom renderers. The summaries report open/focus state, geometry availability, coarse width, and dismissal policy without logging exact widths, coordinates, or bounds.


Anchored popup menu button with keyboard navigation and customizable trigger and item rows.

#![allow(unused)]
fn main() {
use kael::{MenuButtonItem, menu_button};

menu_button("file-actions", [
    MenuButtonItem::new(Action::Rename, "Rename"),
    MenuButtonItem::new(Action::Delete, "Delete"),
])
.label("Actions")
.render_trigger_with(|state, _window, _cx| {
    tracing::info!(summary = state.to_text(), "menu trigger");
    menu_trigger(state.open).into_any_element()
})
.render_items_with(|state, _window, _cx| {
    tracing::info!(summary = state.to_text(), "menu item");
    menu_item_row(state.label, state.highlighted, state.disabled).into_any_element()
})
.on_select({
    let entity = entity.clone();
    move |action, _window, cx| {
        entity.update(cx, |this, cx| {
            this.perform(*action);
            cx.notify();
        });
    }
})
}

Builder methods:

MethodDescription
.label(text)Trigger label text
.on_select(handler)Item selection (|&T, window, cx|)
.render_trigger_with(renderer)Custom trigger rendering with MenuButtonTriggerRenderState
.render_items_with(renderer)Custom item row rendering with MenuButtonItemRenderState<T>

MenuButtonTriggerRenderState fields: open, label, focused

MenuButtonItemRenderState fields: value, label, index, highlighted, disabled

Use trigger and item to_text() helpers plus has_label(), label_len_bytes(), and item label_len_bytes() in custom renderers. The summaries report open/focus state, item index, highlight/disabled state, and label byte lengths without logging trigger labels, item labels, or item values.


Toast

Transient in-window notification displayed by a ToastStack.

#![allow(unused)]
fn main() {
use kael::{Toast, ToastPosition, ToastStack};
use std::time::Duration;

let toast = Toast::new("Saved")
    .body("Project settings updated")
    .duration(Duration::from_secs(4))
    .position(ToastPosition::BottomRight);

tracing::info!(summary = toast.to_text(), "toast");
toast_stack.update(cx, |stack, cx| stack.push(toast, window, cx));
}

Builder methods:

MethodDescription
.body(text)Optional secondary text
.duration(duration)Auto-dismiss duration
.position(position)Screen position

Use toast.to_text(), has_body(), title_len_bytes(), body_len_bytes(), duration_class(), and position_key() before pushing generated notifications. The summary reports text lengths, body presence, coarse duration, and position without logging title/body text or exact seconds. ToastPosition::to_text() returns stable keys for tests and traces.


Splitter

Controlled splitter handle for resizable panes with keyboard, drag, and undo/redo support.

#![allow(unused)]
fn main() {
use kael::{px, splitter};

splitter("sidebar-width", self.sidebar_width)
    .min(px(180.0))
    .max(px(420.0))
    .step(px(8.0))
    .on_change({
        let entity = entity.clone();
        move |width, _window, cx| {
            entity.update(cx, |this, cx| {
                this.sidebar_width = *width;
                cx.notify();
            });
        }
    })
}

Builder methods:

MethodDescription
.min(px)Minimum splitter position
.max(px)Maximum splitter position
.step(px)Keyboard and drag snap increment
.discrete()Snap drag updates down to step values
.horizontal()Horizontal rule that moves vertically
.on_change(handler)Position change (|&Pixels, window, cx|)
.render_with(renderer)Custom rendering with SplitterRenderState

SplitterRenderState fields: value, min, max, vertical, percentage, dragging, focused

Use state.to_text(), orientation(), position_class(), is_at_min(), and is_at_max() in custom renderers. The summary reports orientation, coarse position, edge state, dragging, and focus without logging exact pixel values or fractions.


Label

Text label that can forward focus to another control.

#![allow(unused)]
fn main() {
use kael::label;

let label = label("project-name-label")
    .text("Project name")
    .for_focus_handle(name_focus.clone());

tracing::info!(summary = label.to_text(), "label");
}

Builder methods:

MethodDescription
.text(text)Visible and accessible label text
.for_focus_handle(handle)Focus target control when clicked

Use label.to_text(), has_text(), text_len_bytes(), has_target_focus(), and child_count() before rendering generated form rows. The summary reports text presence, text length, focus-target presence, and custom child count without logging label text or child contents.


ScrollBar

Focusable scroll bar bound to a scrollable container through a ScrollHandle.

#![allow(unused)]
fn main() {
use kael::{scroll_bar, px};

scroll_bar("results-scroll", self.scroll_handle.clone())
    .step(px(48.0))
    .render_with(|state, bounds, window, _cx| {
        tracing::info!(summary = state.to_text(), "scroll bar");
        paint_custom_scrollbar(state, bounds, window);
    })
}

Builder methods:

MethodDescription
.horizontal()Render a horizontal scroll bar
.step(px)Keyboard scroll increment
.render_with(renderer)Custom rendering with ScrollBarRenderState

ScrollBarRenderState fields: vertical, logical_offset, max_offset, viewport_size, content_size, percentage, thumb_ratio, dragging, focused, opacity

Use state.to_text(), orientation(), has_overflow(), position_class(), thumb_size_class(), opacity_class(), is_at_start(), and is_at_end() in custom renderers. The summary reports orientation, overflow, coarse scroll position, thumb class, drag/focus state, and visibility class without logging exact offsets, sizes, ratios, coordinates, or opacity.


Lists

Virtualized native lists for large collections, file explorers, queues, and reorderable settings.

#![allow(unused)]
fn main() {
use kael::{ListAlignment, ListState, list, px};

let list_state = ListState::new(items.len(), ListAlignment::Top, px(240.0));
tracing::info!(summary = list_state.to_text(), "list state");

list_state.set_scroll_handler(|event, _window, _cx| {
    tracing::info!(summary = event.to_text(), "list scroll");
});

list(list_state.clone(), |index, _window, _cx| {
    render_row(index).into_any_element()
})
}

List helpers:

HelperDescription
ListState::to_text()Content-safe item count, alignment, viewport, visible-count, scroll, overflow, and position summary
ListScrollEvent::to_text()Content-safe visible range, visible count, item count, and scrolled state
UniformList::to_text()Content-safe item count, measurement index, decoration count, scroll tracking, and sizing summary
UniformListScrollHandle::to_text()Content-safe pending scroll-to-item intent, strategy, offset-in-items, scrollability, and flip summary
RecyclingList::to_text()Content-safe delegate item count, sizing, alignment, and coarse overdraw summary
ListAlignment::to_text()Stable top / bottom key
ListSizingBehavior::to_text()Stable infer / auto key
ListHorizontalSizingBehavior::to_text()Stable horizontal sizing key
ScrollStrategy::to_text()Stable top / center / bottom key

Use item_count(), alignment(), has_viewport(), visible_range(), visible_item_count(), has_scroll_offset(), has_overflow(), and scroll_position_class() when generated list UIs, diagnostics, or agents need stable state. Summaries report counts, item indexes/ranges, and coarse scroll classes without logging row contents, measured heights, viewport pixels, or scroll offsets.

Use UniformList::to_text() before rendering large fixed-row collections and UniformListScrollHandle::to_text() before generated scroll-to-item commands. Use RecyclingList::to_text() for heterogeneous feeds or inspectors that rely on delegate-provided estimated heights. These wrapper summaries report list configuration and scroll intent without rendering rows or logging row contents, measured heights, exact overdraw pixels, viewport geometry, or scroll offsets.

For sortable lists, call sortable_reorder_plan(source, insertion, count) and log plan.to_text() before applying generated reorder mutations. Inspect has_move(), is_noop(), is_out_of_range(), and target() to separate valid moves from no-op or invalid drops. Use sortable_auto_scroll_class(...) for drag-edge diagnostics without logging pointer coordinates.


Semantic Primitives and Navigation

Native app structure for menus, links, trees, panes, dialogs, alerts, and route stacks without embedding DOM history or browser accessibility nodes.

#![allow(unused)]
fn main() {
use kael::{link, menu_item, tree_item, Navigator, Route, Transition};

let docs = link("docs")
    .label("Docs")
    .url("https://example.com/docs");
tracing::info!(summary = docs.to_text(), "semantic link");

let item = tree_item("src").label("src").selected(true).expanded(true);
tracing::info!(summary = item.to_text(), "tree item");

let nav = Navigator::new(Route::new("home", cx.new(|_| HomeView)));
tracing::info!(summary = nav.to_text(), "navigator");
}

Semantic helpers:

HelperDescription
MenuEntry::to_text()Label presence/byte length, disabled state, callback wiring, and child count
Link::to_text()Label/URL presence and byte lengths, disabled state, activation mode, and child count
TreeItem::to_text()Label byte length, selected state, expansion state, disabled state, activation mode, and child count
Route::to_text()Route-id byte length and memento presence
RouteChangeEvent::to_text()Previous/current route presence, route-id byte lengths, and stack depth
Transition::to_text()Stable transition key
Navigator::to_text()Stack depth, current-route presence, current route-id byte length, and active transition key

Use these summaries when generated app chrome, sidebars, command menus, route stacks, or tree views need native structure that would otherwise be modeled with DOM nodes and browser history. The summaries do not log menu labels, link URLs, tree labels, route IDs, mementos, child contents, or callback internals.


Select

Dropdown with popup menu, optional search, and generic value types.

#![allow(unused)]
fn main() {
use kael::select;

select("accent", self.accent, [
    (AccentColor::Blue, "Atlantic"),
    (AccentColor::Green, "Forest"),
    (AccentColor::Orange, "Ember"),
])
.placeholder("Choose an accent")
.searchable()
.on_change({
    let entity = entity.clone();
    move |value, _window, cx| {
        entity.update(cx, |this, cx| {
            this.accent = *value;
            cx.notify();
        });
    }
})
}

Builder methods:

MethodDescription
.placeholder(text)Placeholder when nothing selected
.searchable()Enable type-to-filter in popup
.on_change(handler)Selection change (|&T, window, cx|)
.render_with(renderer)Custom trigger rendering with SelectRenderState
.render_options_with(renderer)Custom option row rendering with SelectOptionRenderState<T>
.render_popup_with(renderer)Custom popup shell with SelectPopupRenderState
.render_search_with(renderer)Custom search field with SelectSearchRenderState

SelectRenderState fields: open, display_text, selected_label, placeholder, showing_placeholder, focused

For custom renderers, use SelectRenderState::to_text(), SelectOptionRenderState::to_text(), SelectPopupRenderState::to_text(), and SelectSearchRenderState::to_text() for content-safe diagnostics. The helpers report open/focus state, placeholder and selected-label presence, option index, selected/highlighted state, filtered counts, highlighted/selected index presence, search activity, and string byte lengths without logging display text, option labels, placeholder text, search queries, option values, popup widths, or coordinates.


DatePicker

Calendar-based date selection with month/year navigation.

#![allow(unused)]
fn main() {
use kael::date_picker;
use time::Date;

date_picker("delivery", self.delivery_date)
    .on_change({
        let entity = entity.clone();
        move |date, _window, cx| {
            entity.update(cx, |this, cx| {
                this.delivery_date = *date;
                cx.notify();
            });
        }
    })
}

Builder methods:

MethodDescription
.on_change(handler)Date selection (|&Date, window, cx|)
.render_with(renderer)Custom trigger rendering with DatePickerRenderState
.render_days_with(renderer)Custom day cell rendering with DatePickerDayRenderState
.render_popup_with(renderer)Custom popup shell with DatePickerPopupRenderState
.render_header_with(renderer)Custom month header with DatePickerHeaderRenderState
.render_nav_buttons_with(renderer)Custom month navigation buttons
.render_weekdays_with(renderer)Custom weekday labels

DatePickerDayRenderState fields: date, day, selected, highlighted, disabled

For custom renderers, use DatePickerRenderState::to_text(), DatePickerDayRenderState::to_text(), DatePickerPopupRenderState::to_text(), DatePickerHeaderRenderState::to_text(), DatePickerNavButtonRenderState::to_text(), and DatePickerWeekdayRenderState::to_text() for content-safe diagnostics. The helpers report open/focus state, label byte lengths, selectable/selected/ highlighted day state, selected-highlighted relation, navigation availability, button direction/enabled state, and weekday index without logging exact dates, month names, weekday labels, button labels, popup widths, or coordinates.

Display & Feedback

Elements for showing information and providing feedback to users.


Text

Basic text rendering. Strings passed to .child() automatically become text elements:

#![allow(unused)]
fn main() {
div().child("Hello, world!")

div().text_xl().text_color(rgb(0x2563eb)).child("Title")
}

For styled inline text, use SharedString:

#![allow(unused)]
fn main() {
use kael::SharedString;

let label: SharedString = "Click me".into();
div().child(label)
}

Label

Accessible label that forwards focus to a target control:

#![allow(unused)]
fn main() {
use kael::label;

label("Email address", "email-input")
// Clicking the label focuses the text_input with id "email-input"
}

Icon

Render named icons from the icon set:

#![allow(unused)]
fn main() {
use kael::icon;

icon("folder")
icon("file").size(px(16.0))
}

Image

Display raster images with caching:

#![allow(unused)]
fn main() {
use kael::{img, ImageSource};

img(ImageSource::from_path("photo.png"))
    .w(px(200.0))
    .h(px(150.0))
    .rounded_md()
}

SVG

Render SVG content:

#![allow(unused)]
fn main() {
use kael::svg;

svg()
    .path("icons/logo.svg")
    .w(px(24.0))
    .h(px(24.0))
    .text_color(rgb(0x2563eb))  // fills SVG with color
}

RichText

Compose text from inline styled spans, clickable entities (links, mentions, hashtags), inline code, and embedded elements:

#![allow(unused)]
fn main() {
use kael::{rich_text, FontWeight, HighlightStyle, rgb};

rich_text()
    .text("The ")
    .styled("quick brown fox", HighlightStyle {
        color: Some(rgb(0xb45309).into()),
        font_weight: Some(FontWeight::BOLD),
        ..Default::default()
    })
    .text(" jumps. See the ")
    .link("docs", "https://augani.github.io/kael/", |_window, _app| {})
    .text(" or ping ")
    .mention("@team", "team-id", |_window, _app| {})
    .text(". Run ")
    .code("cargo run")
    .selectable(true)
}

Builder methods: .text(), .styled(text, HighlightStyle), .link(text, target, on_click), .mention(text, payload, on_click), .hashtag(text, payload, on_click), .code(text), .inline_element(element), .selectable(bool), .selection_color(color). Entity click handlers have the signature Fn(&mut Window, &mut App).


Progress

Determinate or indeterminate progress indicator:

#![allow(unused)]
fn main() {
use kael::progress;

// Determinate (0.0 to 1.0)
progress("export", 0.65)

// Indeterminate
progress("loading", 0.0).indeterminate()

// Custom rendering
progress("download", self.progress)
    .render_with(|state, bounds, window, _cx| {
        // state.percentage: Option<f64>
        // state.indeterminate: bool
        // Paint track and fill bar using window.paint_quad()
        window.paint_quad(fill(bounds, rgb(0xe2e8f0)).corner_radii(px(4.0)));
        if let Some(pct) = state.percentage {
            let width = bounds.size.width * pct as f32;
            window.paint_quad(fill(
                Bounds::new(bounds.origin, size(width, bounds.size.height)),
                rgb(0x2563eb),
            ).corner_radii(px(4.0)));
        }
    })
}

ProgressRenderState fields: value, max, percentage, indeterminate


Toast

Auto-dismissing notification overlay:

#![allow(unused)]
fn main() {
use kael::{Toast, ToastStack};

// In your view, create a ToastStack entity
struct MyApp {
    toasts: Entity<ToastStack>,
}

// Create it
let toasts = cx.new(|_| ToastStack::new());

// Push a toast from anywhere with the entity handle
toasts.update(cx, |stack, cx| {
    stack.push(
        Toast::new("File saved")
            .body("changes written to disk")
            .duration(Duration::from_secs(3)),
        window,
        cx,
    );
});

// Render the stack in your view
impl Render for MyApp {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .size_full()
            .child(/* your content */)
            .child(self.toasts.clone()) // ToastStack renders as overlay
    }
}
}

Toast positions: ToastPosition::TopRight, TopCenter, BottomRight


Canvas

GPU-accelerated custom drawing surface:

#![allow(unused)]
fn main() {
use kael::{Bounds, canvas, point, px, rgb, size};

canvas(size(px(400.0), px(300.0)), |draw, _window, _cx| {
    draw.reserve_commands(3);
    draw.fill_rects([
        (
            Bounds::new(point(px(16.0), px(40.0)), size(px(72.0), px(180.0))),
            rgb(0x2563eb).into(),
        ),
        (
            Bounds::new(point(px(104.0), px(80.0)), size(px(72.0), px(140.0))),
            rgb(0x60a5fa).into(),
        ),
    ]);
    draw.fill_circles([(
        point(px(280.0), px(110.0)),
        px(36.0),
        rgb(0xf59e0b).into(),
    )]);
})
}

fill_rects batches axis-aligned quads. fill_circles uses the rounded-quad fast path rather than path tessellation, and reserve_commands avoids command buffer growth during predictable real-time frames.

Containers & Overlays

Components for organizing content, managing layers, and showing floating UI.


Controlled dialog overlay with backdrop, escape-to-dismiss, and click-outside handling:

#![allow(unused)]
fn main() {
use kael::modal;

modal("confirm-dialog", self.is_open)
    .label("Confirm action")
    .backdrop(hsla(0.0, 0.0, 0.0, 0.5))
    .dismiss_on_escape(true)
    .dismiss_on_click_outside(true)
    .render_with({
        let entity = entity.clone();
        move |state, _window, _cx| {
            div()
                .w(px(400.0))
                .p_6()
                .bg(rgb(0xffffff))
                .rounded(px(12.0))
                .shadow_xl()
                .flex().flex_col().gap_4()
                .child(div().text_lg().child("Are you sure?"))
                .child(div().child("This action cannot be undone."))
                .child(
                    div().flex().justify_end().gap_2()
                        .child(button("cancel").label("Cancel")
                            .on_click({
                                let entity = entity.clone();
                                move |_, _, cx| {
                                    entity.update(cx, |this, cx| {
                                        this.is_open = false;
                                        cx.notify();
                                    });
                                }
                            }))
                        .child(button("confirm").label("Confirm")
                            .on_click({
                                let entity = entity.clone();
                                move |_, _, cx| {
                                    entity.update(cx, |this, cx| {
                                        this.do_action();
                                        this.is_open = false;
                                        cx.notify();
                                    });
                                }
                            }))
                )
                .into_any_element()
        }
    })
    .on_change({
        let entity = entity.clone();
        move |open, _window, cx| {
            entity.update(cx, |this, cx| {
                this.is_open = *open;
                cx.notify();
            });
        }
    })
}

ModalRenderState fields: open, label, focused


Popover

Anchored floating panel with positioning:

#![allow(unused)]
fn main() {
use kael::popover;

popover("color-picker")
    .anchor(|_window, _cx| {
        button("show-colors").label("Colors").into_any_element()
    })
    .popup(|_window, _cx| {
        div()
            .w(px(200.0))
            .p_3()
            .bg(rgb(0xffffff))
            .shadow_lg()
            .rounded(px(8.0))
            .child("Color picker content")
            .into_any_element()
    })
    .dismiss_on_escape(true)
    .dismiss_on_click_outside(true)
}

Tabs

Tabbed content switcher with keyboard navigation:

#![allow(unused)]
fn main() {
use kael::tabs;

#[derive(Clone, Copy, PartialEq, Eq)]
enum EditorTab { Code, Preview, Settings }

tabs("editor-tabs", self.active_tab, [
    TabItem::new(EditorTab::Code, "Code", |_w, _cx| {
        div().child("Code editor here").into_any_element()
    }),
    TabItem::new(EditorTab::Preview, "Preview", |_w, _cx| {
        div().child("Live preview").into_any_element()
    }),
    TabItem::new(EditorTab::Settings, "Settings", |_w, _cx| {
        div().child("Editor settings").into_any_element()
    }),
])
.on_change({
    let entity = entity.clone();
    move |tab, _window, cx| {
        entity.update(cx, |this, cx| {
            this.active_tab = *tab;
            cx.notify();
        });
    }
})
}

TabRenderState fields: value, label, index, tab_count, selected, focused


Disclosure

Collapsible section (accordion):

#![allow(unused)]
fn main() {
use kael::disclosure;

disclosure("advanced-settings", self.expanded)
    .trigger(|_w, _cx| {
        div().child("Advanced Settings ▾").into_any_element()
    })
    .panel(|_w, _cx| {
        div().p_3().child("Hidden content here").into_any_element()
    })
    .on_change({
        let entity = entity.clone();
        move |open, _window, cx| {
            entity.update(cx, |this, cx| {
                this.expanded = *open;
                cx.notify();
            });
        }
    })
}

Splitter

Draggable pane divider for resizable layouts:

#![allow(unused)]
fn main() {
use kael::splitter;

splitter("main-split", self.split_ratio)
    .on_change({
        let entity = entity.clone();
        move |ratio, _window, cx| {
            entity.update(cx, |this, cx| {
                this.split_ratio = *ratio;
                cx.notify();
            });
        }
    })
}

Use the ratio value to size adjacent panes:

#![allow(unused)]
fn main() {
let left_width = self.split_ratio * total_width;
div().flex().flex_row()
    .child(div().w(px(left_width)).child("Left pane"))
    .child(splitter("split", self.split_ratio).on_change(/* ... */))
    .child(div().flex_1().child("Right pane"))
}

Context Menu

Right-click menus via .context_menu() on any Div:

#![allow(unused)]
fn main() {
div()
    .id("file-item")
    .child("document.txt")
    .context_menu(|menu| {
        menu.item("Open", |_w, cx| { /* handle open */ })
            .item("Rename", |_w, cx| { /* handle rename */ })
            .separator()
            .item("Delete", |_w, cx| { /* handle delete */ })
    })
}

Tooltip

Hover information via .tooltip() on any Div:

#![allow(unused)]
fn main() {
div()
    .id("save-icon")
    .child(icon("save"))
    .tooltip("Save file (Cmd+S)")

// Custom tooltip content
div()
    .id("status")
    .child("●")
    .tooltip_element(|| {
        div()
            .p_2()
            .bg(rgb(0x1E1E1E))
            .text_color(rgb(0xffffff))
            .rounded(px(4.0))
            .child("Connected to server")
    })
}

Layer

Managed layer system for in-window modals and popovers:

#![allow(unused)]
fn main() {
use kael::layer;

layer("notification-layer")
    .placement(LayerPlacement::Centered)
    .child(/* floating content */)
}

Navigation

Multi-screen apps push and pop views on a navigation stack. The canonical API is kael::Navigator in the core crate.

#![allow(unused)]
fn main() {
use kael::{Navigator, Route, Transition, navigator};

let nav = navigator(Route::new("inbox", inbox_view));

nav.push(
    Route::new("thread", thread_view).with_memento(ThreadScroll { offset }),
    Transition::SlideLeft,
    window,
    cx,
);

nav.pop(Transition::SlideRight, window, cx);
}
  • Routes pair a stable id with an AnyView.
  • Mementos carry restorable per-route state (scroll positions, selections): attach with with_memento, read back with route.memento::<T>() when the route resurfaces.
  • Events: Navigator emits RouteChangeEvent, so views can cx.subscribe to react to navigation (analytics, focus restoration).
  • Transitions: pushes and pops animate with Transition (slide, fade, or custom).

Navigator is a screen stack, not a URL router — there is no path matching or deep linking yet; those are tracked for a future release.

Navigator also offers replace, replace_stack, and pop_to_root for rewriting the stack, each taking a Transition to animate the change.

Theming

Kael has one theming pipeline with two cooperating types, each with a clear role:

  • kael::Theme — the serializable, file-facing theme. It is what a JSON/TOML theme file deserializes into (colors, typography, spacing, radii, shadows), and it is what the hot-reload file watcher reloads. Think of it as the theme on disk.
  • kael_ui::Theme (a variant plus a rich [ThemeTokens]) — the runtime token system that components actually render from. Every kael_ui component reads Theme::of(cx).tokens.*. Think of it as the theme in memory.

A bridge connects them so that editing a theme file restyles live components.

The pipeline

  theme.toml / theme.json          (you edit this)
        │  file watcher (App::observe_theme_file)
        ▼
  kael::Theme                      (parsed, stored as a Global)
        │  App::observe_theme_files subscriber
        ▼
  kael_ui::ThemeTokens             (core fields mapped onto current tokens)
        │  install_theme  →  set_global + refresh_windows
        ▼
  components                       (re-render with Theme::of(cx).tokens.*)

Each stage is one observable hop: a file edit walks all the way down to a visible restyle, with no restart.

Reading the theme in components

Components read tokens through the zero-clone borrow:

#![allow(unused)]
fn main() {
impl Render for MyView {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let theme = Theme::of(cx);

        div()
            .bg(theme.tokens.background)
            .text_color(theme.tokens.foreground)
            .border_color(theme.tokens.border)
            .child(
                div()
                    .bg(theme.tokens.primary)
                    .text_color(theme.tokens.primary_foreground)
                    .px(px(16.0))
                    .py(px(8.0))
                    .rounded(theme.tokens.radius_md)
                    .child("Primary button"),
            )
    }
}
}

Theme::of(cx) (alias Theme::get(cx)) borrows the theme from the app's global state without cloning. use_theme() is a legacy clone-per-call shim retained for call sites where the borrow checker cannot take a &Theme cleanly; prefer Theme::of(cx) everywhere else.

Presets, custom themes, and live switching

kael_ui ships 18 presets and lets you brand your app from any of them:

#![allow(unused)]
fn main() {
kael_ui::init(cx);
install_theme(cx, Theme::dark());

// Brand it: start from a preset's tokens and override what you need.
let brand = Theme::custom(ThemeTokens {
    primary: hsla(262.0 / 360.0, 0.83, 0.58, 1.0),
    radius_md: px(10.0),
    ..ThemeTokens::dark()
});
install_theme(cx, brand);
}

install_theme stores the active Theme as a Global and refreshes every open window, so re-installing at runtime switches themes live. See Component Library.

Theme files

A theme file deserializes into a kael::Theme. Fields are grouped; omit any section to keep its defaults.

[colors]
background = "#0b1020"
surface    = "#161c2e"
primary    = "#6366f1"
accent     = "#22d3ee"
muted      = "#3b4252"
foreground = "#e5e7eb"
border     = "#2a3350"
error      = "#ef4444"

[radii]
sm = 4.0
md = 8.0
lg = 12.0
xl = 16.0

[typography]
ui_font_family   = "Inter"
code_font_family = "JetBrains Mono"

The same shape works as JSON. Load one directly with:

#![allow(unused)]
fn main() {
let theme = kael::Theme::from_path("themes/active.toml")?;
cx.set_global(theme);
}

Hot-reload end-to-end

Wire the file watcher and the bridge once during startup. After that, every save to the watched file restyles the live UI:

#![allow(unused)]
fn main() {
Application::new().run(move |cx| {
    kael_ui::init(cx);
    install_theme(cx, Theme::dark());

    // Register the bridge: maps reloaded kael::Theme -> ThemeTokens, then
    // install_theme (refreshing all windows).
    install_theme_file_bridge(cx);

    // Watch the file; on_change updates the core kael::Theme global, which
    // fires the bridge subscriber registered above.
    cx.observe_theme_file("themes/active.toml", |theme, cx| cx.set_global(theme))
        .expect("failed to watch theme file");

    // ... open your window; components read Theme::of(cx).tokens.*
});
}

Order matters only in that the bridge must be registered before (or alongside) the watcher; observe_theme_file applies the initial file once, and every later save flows through the same path. The Astryx showcase demonstrates live theme switching alongside branded components.

Core → token mapping

When a theme file reloads, the bridge maps the loaded kael::Theme onto the currently installed ThemeTokens. Token fields without a core source are preserved, so a partial file changes only what it names.

core kael::Theme fieldThemeTokens field(s)
colors.backgroundbackground
colors.foregroundforeground
colors.surfacecard, popover
colors.primaryprimary, ring
colors.accentaccent
colors.mutedmuted
colors.borderborder, input
colors.errordestructive
radii.sm / md / lg / xlradius_sm/md/lg/xl
shadows.sm / md / lgshadow_sm/md/lg
typography.ui_font_familyfont_family
typography.code_font_familyfont_mono

Fields with no core source keep their existing token values: the *_foreground colors, secondary, muted_foreground, accent_foreground, shadow_xs, shadow_xl, ring_offset, and the spacing / duration / z-index scales. Core fields with no token target (separator, selected_text, warning, success, radii.pill, and the typographic sizes/weights) are intentionally not mapped.

If you need a different mapping, call tokens_from_core_theme(core, base) yourself inside a custom cx.observe_theme_files subscriber.

Accessibility

Kael's built-in widgets are accessible by default — every form control reports its role, state, and value to screen readers and supports full keyboard navigation.

Built-in accessibility

All form controls automatically provide:

  • Roles: Button reports as button, checkbox as checkbox, etc.
  • States: Focused, disabled, checked, selected, expanded
  • Values: Slider reports its numeric value, progress reports percentage
  • Labels: Set via .label() builder method
  • Keyboard navigation: Tab between controls, Space/Enter to activate

You get this for free when using the built-in widgets.

Adding accessibility to custom elements

For custom div-based interactive elements, add accessibility attributes:

#![allow(unused)]
fn main() {
let accessibility = AccessibilityAttributes::switch("Enable dark mode", self.is_on);
accessibility.validate()?;

div()
    .id("custom-toggle")
    .accessibility(accessibility)
    .on_click(|_, _, cx| { /* toggle */ })
}

Semantic recipes are available for common custom controls:

#![allow(unused)]
fn main() {
AccessibilityAttributes::button("Save")
AccessibilityAttributes::link("Open documentation")
AccessibilityAttributes::checkbox("Email notifications", enabled)
AccessibilityAttributes::switch("Enable sync", enabled)
AccessibilityAttributes::radio_button("Compact", selected)
AccessibilityAttributes::slider("Volume", value, 0.0, 100.0, Some(1.0))
AccessibilityAttributes::progress_bar("Upload progress", progress, 0.0, 100.0)
AccessibilityAttributes::text_input("Search", query.clone())
}

Use .validate()? in tests or builder code to catch unlabeled interactive controls, missing actions, and invalid ranges before the UI renders.

Keyboard navigation

Focus management

#![allow(unused)]
fn main() {
// Create a focus handle
let focus = cx.focus_handle();

div()
    .id("panel")
    .track_focus(&focus)
    .on_key_down(|event, window, cx| {
        match event.keystroke.key.as_str() {
            "enter" => { /* activate */ },
            "escape" => { /* cancel */ },
            _ => {}
        }
    })
}

Tab stops

Controls with IDs are automatically tab-focusable. Custom tab order:

#![allow(unused)]
fn main() {
div()
    .id("first-field")
    .tab_index(1)

div()
    .id("second-field")
    .tab_index(2)
}

Focus traps

For custom modals, popovers, command palettes, and inspector panels, use the headless FocusTrapController so Tab, Shift-Tab, and Escape behave like users expect without tying the behavior to one visual component:

#![allow(unused)]
fn main() {
use kael_ui::prelude::{FocusTrapAction, FocusTrapController};

let trap = FocusTrapController::modal();
let root_focus = cx.focus_handle();

if trap.should_autofocus() {
    window.focus(&root_focus);
}

div()
    .id("settings-dialog")
    .track_focus(&root_focus)
    .tab_index(0)
    .on_key_down(move |event, window, cx| {
        match trap.action_for_keystroke(&event.keystroke) {
            Some(FocusTrapAction::FocusNext) => {
                window.focus_next_in_group();
                cx.stop_propagation();
                window.prevent_default();
            }
            Some(FocusTrapAction::FocusPrevious) => {
                window.focus_prev_in_group();
                cx.stop_propagation();
                window.prevent_default();
            }
            Some(FocusTrapAction::Dismiss) => {
                close_settings(window, cx);
            }
            Some(FocusTrapAction::FocusFirst) | None => {}
        }
    })
    .child(/* first focusable child */)
    .child(/* second focusable child */)
}

Use FocusTrapController::persistent() for surfaces where Escape should not dismiss, or .dismiss_on_escape(false) / .autofocus(false) to tune a trap.

Assistive-technology actions

Advertised actions tell screen readers and other assistive technologies what a custom element can do:

#![allow(unused)]
fn main() {
let attrs = AccessibilityAttributes::switch("Enable sync", enabled)
    .action(AccessibilityAction::Toggle);
}

When a platform adapter or custom integration receives a native action request, Kael normalizes it into its action vocabulary. Apps can register handlers on the window:

#![allow(unused)]
fn main() {
window.on_accessibility_action(
    accessibility_id,
    AccessibilityAction::Toggle,
    |request| {
        assert_eq!(request.action, AccessibilityAction::Toggle);
        toggle_sync();
    },
);

let requests = window.drain_accessibility_actions();
}

Value-setting actions carry payload data:

#![allow(unused)]
fn main() {
window.on_accessibility_action(
    slider_id,
    AccessibilityAction::SetValue,
    |request| {
        if let Some(AccessibilityActionPayload::NumericValue(value)) = request.payload {
            set_volume(value);
        }
    },
);

window.on_accessibility_action(
    search_id,
    AccessibilityAction::SetValue,
    |request| {
        if let Some(AccessibilityActionPayload::Value(value)) = request.payload {
            set_search_query(value);
        }
    },
);
}

For custom test harnesses or non-window integrations, route normalized requests through AccessibilityActionRouter:

#![allow(unused)]
fn main() {
let node = attrs.to_node(accessibility_id);
let mut router = AccessibilityActionRouter::new();

router.on_action(accessibility_id, AccessibilityAction::Toggle, |request| {
    assert_eq!(request.action, AccessibilityAction::Toggle);
    toggle_sync();
});

router.dispatch_accesskit(accessibility_id, &node, accesskit::Action::Click);
}

AccessibilityActionRequest::from_accesskit_for_node(...) uses the node's advertised actions to recover Kael-specific meaning when a platform action is coarser than Kael's vocabulary. For example, AccessKit Click can normalize to Toggle for a switch or ShowMenu for a combobox when that is what the node declared.

Label association

Use the label element to associate labels with controls:

#![allow(unused)]
fn main() {
label("Email address", "email-input")
// Clicking the label focuses the associated input

text_input("email-input", self.email.clone())
}

Screen reader announcements

#![allow(unused)]
fn main() {
// Announce to screen readers
window.announce("File saved successfully");
}

Accessibility roles

RoleUsed by
Buttonbutton()
Checkboxcheckbox()
Radioradio_group() options
Sliderslider()
TextInputtext_input()
Switchtoggle()
Dialogmodal()
Tabtabs()
TabPaneltabs() panel content
ProgressBarprogress()
Menucontext menus
MenuItemmenu items
Treetree views
TreeItemtree items

Platform support

Kael builds one cross-platform accessibility tree per window each frame and hands it to the native platform layer. There is nothing to opt into: any window that renders accessible widgets (or custom elements with AccessibilityRole/aria_* attributes) is exposed automatically.

PlatformBackendStatus
macOSaccesskit_macos SubclassingAdapter over the window's NSViewAdapter-backed; serves a full NSAccessibility tree to VoiceOver
Linuxaccesskit_unix AT-SPI2 adapter (one per window, x11 and wayland)Adapter-backed; exposes the tree on the AT-SPI2 D-Bus bus to Orca
WindowsHand-rolled UI Automation provider (IRawElementProviderSimple)Native UIA, served via WM_GETOBJECT

On macOS and Linux the tree is built once with AccessKit and the official adapters translate it to the platform protocol; Windows keeps its dedicated UIA provider. All three are driven from the same per-frame tree, so widget roles, labels, values, and focus stay consistent across platforms.

Notes:

  • macOS requires no special entitlement; VoiceOver reads the served tree directly. The adapter dynamically subclasses the NSView, so it coexists with the rest of the AppKit window.
  • Linux uses accesskit_unix's default async-io executor, which owns its own background thread for the zbus/AT-SPI2 connection — kael's executors are not involved. AT-SPI2 needs no special permission.
  • Assistive-technology action requests can be normalized with AccessibilityActionRequest and routed with AccessibilityActionRouter. macOS and Linux adapter drains now normalize pending AccessKit requests against the current tree so Kael-specific actions such as Toggle, ShowMenu, and Dismiss survive platform delivery. Windows feeds standard UIA focus, invoke, toggle, expand/collapse, and range-value pattern calls into the same window route.

Testing with a screen reader

macOS (VoiceOver)

Turn VoiceOver on with Cmd-F5, then focus your window and navigate with Ctrl-Option-Arrow. Each control should be announced with its role and value (for example, "Enable notifications, checkbox, checked").

To inspect the served tree without VoiceOver, use Xcode's Accessibility Inspector (Xcode → Open Developer Tool → Accessibility Inspector) and point its target picker at your running app, or query the Accessibility API directly (AXUIElementCreateApplication(pid) walking kAXChildrenAttribute). A window that previously exposed only a single root group will now report the full control hierarchy.

Linux (Orca)

Start Orca (orca &) with your app running. Because Kael registers an accesskit_unix adapter per window, the controls appear on the AT-SPI2 bus and Orca announces them as you Tab through. The accerciser tool can also be used to browse the live AT-SPI2 tree.

Windows (Narrator)

Start Narrator with Ctrl-Win-Enter. The UI Automation provider answers WM_GETOBJECT, so controls are announced by role and name. The Accessibility Insights for Windows tool can inspect the UIA tree.

Lists & Data

High-performance list components with virtualization for rendering thousands of items.


UniformList

Highest-performance list for items of equal, positive height. It measures one row and only renders the visible range, so large logs and tables do not create an element for every record:

#![allow(unused)]
fn main() {
use kael::{uniform_list, UniformListScrollHandle};

let scroll_handle = UniformListScrollHandle::new();

uniform_list(
    "log-entries",
    self.entries.len(),
    {
        let entries = self.entries.clone();
        move |range, _window, _cx| {
            entries[range.clone()]
                .iter()
                .map(|entry| {
                    div()
                        .px_3()
                        .py_1()
                        .text_sm()
                        .child(entry.message.clone())
                        .into_any_element()
                })
                .collect()
        }
    },
)
.track_scroll(scroll_handle.clone())
}

When to use: Log viewers, file lists, data tables — any list where every row has the same height.

The measured row must resolve to a finite height greater than zero. A zero, negative, NaN, or infinite height is treated as an empty viewport for that frame instead of attempting an invalid visible-range calculation. Use with_width_from_item(Some(index)) when a representative row is a better width sample than row zero.


List

Flexible list with alignment and overflow handling:

#![allow(unused)]
fn main() {
use kael::list;

// Basic list
list()
    .child(div().child("Item 1"))
    .child(div().child("Item 2"))
    .child(div().child("Item 3"))
}

RecyclingList

Virtualized list for items with different heights. Supply a delegate with stable estimated heights for rows that have not been measured yet:

#![allow(unused)]
fn main() {
use kael::{
    AnyElement, App, FontWeight, IntoElement, ListDelegate, Pixels, Window, div, px,
    recycling_list,
};
use std::sync::Arc;

#[derive(Clone)]
struct MessageDelegate {
    messages: Arc<[Message]>,
    // Increment when messages are inserted, removed, reordered, or their
    // estimated heights change.
    height_revision: u64,
}

impl ListDelegate for MessageDelegate {
    fn item_count(&self) -> usize {
        self.messages.len()
    }

    fn estimated_item_height(&self, index: usize) -> Pixels {
        let body_lines = self.messages[index].body.lines().count().max(1);
        px(44.0 + body_lines as f32 * 18.0)
    }

    fn estimated_heights_revision(&self) -> Option<u64> {
        Some(self.height_revision)
    }

    fn render_item(&self, index: usize, _window: &mut Window, _cx: &mut App) -> AnyElement {
        let msg = &self.messages[index];
        div()
            .p_3()
            .child(div().font_weight(FontWeight::BOLD).child(msg.sender.clone()))
            .child(div().text_sm().child(msg.body.clone()))
            .into_any_element()
    }
}

recycling_list(
    "messages",
    MessageDelegate {
        messages: self.messages.clone(),
        height_revision: self.message_height_revision,
    },
)
}

When to use: Chat messages, feed items — lists where rows vary in height.

Returning Some(revision) from estimated_heights_revision is the fast path: unchanged frames reuse the existing height sum-tree without an O(total items) estimation pass. Increment the revision whenever count, order, or estimates change. The default return value is None; that is safe for fully dynamic delegates because estimates are refreshed every frame, but it intentionally trades away the steady-state optimization.

Element pooling is opt-in through recycle_key and render_recycled_item. Kael sizes each keyed pool from the observed visible-and-overdraw high-water mark, so large viewports are not constrained by a small fixed pool. Only return elements whose supports_reuse() implementation is true, and fully update recycled content before returning it for a new index.


SortableList

Drag-to-reorder list with auto-scroll and insertion indicator:

#![allow(unused)]
fn main() {
use kael::sortable_list;

sortable_list(
    "layers",
    self.layers.len(),
    {
        let layers = self.layers.clone();
        move |index, _window, _cx| {
            div()
                .px_3()
                .py_2()
                .child(layers[index].name.clone())
                .into_any_element()
        }
    },
)
.on_reorder({
    let entity = entity.clone();
    move |from, to, _window, cx| {
        entity.update(cx, |this, cx| {
            let item = this.layers.remove(from);
            this.layers.insert(to, item);
            cx.notify();
        });
    }
})
}

When to use: Layer panels, playlist editors, kanban columns — anywhere users reorder items by dragging.


ScrollBar

Kael provides automatic scrollbars for any element with overflow_y_scroll() or overflow_y_auto() and a tracked ScrollHandle. The scrollbar appears as a native-style dark rounded thumb when content overflows — no extra code needed (see Layout & Styling).

For custom scroll bar rendering, use the explicit scroll_bar() widget:

#![allow(unused)]
fn main() {
use kael::scroll_bar;

scroll_bar(scroll_handle.clone())
    .render_with(|state, bounds, window, _cx| {
        // Custom scroll bar rendering
        // state.thumb_bounds, state.dragging
    })
}

Patterns

Virtual DataTable selection and paging

DataTable::new_virtual keeps only a bounded LRU of pages and virtualizes both rows and variable-width columns. A million-row select-all is represented as "all except these deselected rows", so it stores zero row indices until a user starts excluding rows:

DataTable::new_virtual(1_000_000, columns, 128, cx)
    .max_cached_pages(8)
    .show_selection(true)
    .on_fetch_page_request(|request, _window, cx| {
        // Fetch or compute only request.page_start()..page_start + page_size.
        // Commit with set_page_data_for(request, rows, cx); stale generations
        // are rejected automatically.
    })
    .on_selection_change_snapshot(|selection, _window, _cx| {
        println!(
            "{} selected; {} stored indices ({})",
            selection.selected_count(),
            selection.stored_index_count(),
            selection.representation_key(),
        );
    })

Use on_selection_change_snapshot for bulk actions. Its DataTableSelectionSnapshot::AllExcept { total_rows, deselected } variant is exact without expansion. The compatibility on_selection_change(&[usize], ...) callback is invoked only when the exact selected indices can be materialized within 16,384 items; it is intentionally skipped for a million-row all-except selection rather than reporting an empty or partial slice. Likewise, selected_rows() returns None for all-except state; this does not mean the selection is empty.

Virtual-table search and sort are query inputs for the backing source. Kael invalidates the current generation and cache; the application or a Kael Web Worker performs the large search/sort and returns the requested page. Kael does not scan or allocate the million-row logical range on the UI thread.

Data table with uniform_list

#![allow(unused)]
fn main() {
struct DataTable {
    rows: Vec<Row>,
    columns: Vec<Column>,
    scroll: UniformListScrollHandle,
}

impl Render for DataTable {
    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        let columns = self.columns.clone();
        let rows = self.rows.clone();

        div().flex().flex_col().size_full()
            .child(self.render_header())
            .child(
                uniform_list("table-body", rows.len(), move |range, _w, _cx| {
                    rows[range.clone()].iter().map(|row| {
                        div().flex().flex_row()
                            .children(columns.iter().map(|col| {
                                div().w(px(col.width)).px_2().py_1()
                                    .child(row.get(&col.key).clone())
                            }))
                            .into_any_element()
                    }).collect()
                })
                .track_scroll(self.scroll.clone())
            )
    }
}
}

Suite-scale Applications

Kael maintains one source-level release workload for the four surfaces common to an office suite. It compiles as a native desktop application and as WebAssembly without changing view code:

# Desktop
cargo run -p kael_ui --example suite_scale_smoke

# Browser package
bash scripts/build-browser-suite-smoke.sh
python3 -m http.server 8133 --directory target/browser-suite-smoke

The reference app is not a DOCX renderer, spreadsheet formula engine, or slide layout engine. It is the framework-scale proof beneath those product layers: it exercises retained views, virtual mounting, bounded caches, editing state, search/undo, immediate canvas batches, spatial culling, damage, rich pointer input, and fixed-step animation using the same Rust source on both targets.

Maintained workload contract

SurfaceLogical workloadPer-view retained work
Sheets1,000,000 rows × 16,384 columnsat most 2,048 mounted cells; 8-tile LRU in the live grid
Docs250,000 blocks (5,209 pages)at most 6 pages / 288 blocks; sparse edits and 64 undo transactions
Slides10,000 slidesat most 16 thumbnails and one retained slide surface
Whiteboard100,000 shapesat most 2,048 visible shapes / 4,096 spatial candidates; 512 KiB tile payload cache

The live example uses VirtualSheetGrid over the full 16,384-column address space. Rows and columns are both mounted from their current viewports, and the model responds to generation-scoped, row-major tile requests. No row vector, column-definition vector, or cell matrix scales with the logical sheet size.

The CI probe also enforces generous regression ceilings: building the 100,000 shape spatial index must finish within 30 seconds and a viewport query within 2 seconds. Typical optimized runs are much faster, but the ceilings tolerate shared CI machines while still catching an accidental linear full-scene render.

Architecture for a product suite

Keep the document model logical and make the view a window into it:

  • A sheet stores sparse edits and bounded row-major tiles, not 16,384 resident strings per row. VirtualSheetGrid de-duplicates generation-safe requests, caps its LRU and pending set, and mounts only intersecting rows and columns. Use a Kael worker for formula recalculation, indexing, or remote queries.
  • A document stores immutable base blocks plus sparse edits. Mount pages with a virtual list, keep undo transactions bounded, and execute full-document search as bounded chunks through the existing worker bridge.
  • A deck mounts only its thumbnail viewport and reuses one retained slide surface as selection changes.
  • A whiteboard indexes retained shape bounds once, queries the viewport through SpatialIndex, invalidates moved bounds with TileDamageTracker, caches only visible tiles, and feeds PointerInputEvent::stroke_samples() into a bounded stroke pipeline. Drive simulations with FixedFrameClock, then request the next host animation frame.

This separation is what makes one codebase portable: native and browser hosts provide windows, input, timing, GPU presentation, storage, workers, and file boundaries while the application owns the same models and retained views.

Exact current boundaries

  • Browser full-document search and spreadsheet recalculation should run through Kael's typed worker bridge; the synchronous reference search API is chunked and capped, but it does not silently create a worker.
  • Browser export cannot include cross-origin WebViews, protected surfaces, or other live hosted content. Export the retained scene before mounting such a surface, or export the hosted document through its own API.
  • Browser pointer events include mouse, simultaneous touch, pen pressure/tilt, capture, cancellation, and bounded coalesced samples. Desktop compatibility mouse input uses the same event type; raw native touch/pen streams still depend on their platform backends. Browser pointer lock is exposed through the portable game-input API; synthesized pinch coverage remains partial.
  • Browser secondary windows are independent retained canvases hosted inside the page, not operating-system windows. The smoke proves focus, presentation, and close cleanup for that browser model.
  • Retained GPU presentation and device-pixel scene export have release coverage for WebGL 2 in browsers, Metal on macOS, Direct3D 11 on Windows, and the Blade/Vulkan X11 surface path on Linux. Linux hosted CI selects lavapipe, so that gate proves software-renderer correctness and liveness rather than hardware throughput or native-Wayland compositor integration.
  • The browser owns IME candidate UI, native print settings, and security policy around cross-origin/network/file access. Kael exposes typed capability reports for these boundaries rather than claiming unavailable desktop behavior.

Run bash scripts/verify-browser-suite-smoke.sh before release to execute the workload in real Chrome at both 1280 × 720 and 760 × 720, including compressed select-all, bounded page cache, virtual page/thumbnail mounting, retained whiteboard drawing, responsive offscreen mounting, and a synthetic pen sequence through the browser pointer bridge. The same gate also proves:

  • the primary and a focused secondary Kael window present independent, non-uniform retained pixels; closing the secondary surface restores focus to the primary and removes its browser host;
  • on_open_urls receives the current URL across real hashchange, history.back(), and history.forward() transitions without a document reload;
  • the presented WebGL frame exports as a nontrivial PNG before any hosted live surface is mounted; and
  • the million-row, 16,384-column live grid mounts no more than 64 rows, 16 columns, or 1,024 cells, and keeps the whole primary accessibility tree at or below 768 mounted semantic nodes. These are counts from the rendered browser accessibility tree, not only a model-side prediction.

The smoke registers on_reopen, but CI does not pretend to force a browser back-forward-cache restore. Browser pagehide/suspension is not synthesized as a native quit. Test BFCache restoration separately in products that depend on that lifecycle detail.

Animations

Kael drives animations from its render-on-demand loop: an animating element requests frames only while it is in flight, then the window returns to idle (0% CPU). There are three layers — implicit transitions that ease style changes automatically, explicit time-driven animations you attach to any element, and the framework's built-in motion such as elastic scrolling.

Implicit transitions

The web's "soft" feel comes from transition: all 150ms ease; Kael's equivalent is .transition(duration) on any element with a stable id. Whenever the element's computed style changes — hover, active, focus, or a state-driven restyle — the change is interpolated instead of snapping:

#![allow(unused)]
fn main() {
use std::time::Duration;

div()
    .id("cta")
    .bg(theme.tokens.primary)
    .rounded(px(10.))
    .transition(Duration::from_millis(150))
    .hover(|style| style.bg(theme.tokens.accent).rounded(px(16.)))
    .active(|style| style.scale(0.97))
}

Animated properties: background (including gradients with matching stop counts), border color, text color, opacity, corner radii, box shadows, rotation, and scale. transition_with(duration, easing) takes an explicit easing curve; transitions interrupt cleanly, retargeting from the current visual state. kael_ui's controls ship with this wired to the transition_fast theme token.

Layout (FLIP) animation

.animate_layout(duration) makes a keyed element glide to its new position when layout moves it — list reorders, grid changes, sidebar toggles:

#![allow(unused)]
fn main() {
div().id(item.id).animate_layout(Duration::from_millis(350))
}

Avoid it on children of containers that scroll mid-animation; scrolling moves the element and restarts the glide. The Astryx showcase's layout and motion sections demonstrate this API in a complete application.

Springs and gestures

For physics-driven motion, kael_ui provides SpringValue/SpringPoint (real spring integration with velocity, presets from SpringPreset) and DraggableSpring, a container you can drag and throw: on release, the pan gesture's velocity hands off to the spring, which settles to the nearest snap point. The Astryx showcase includes the production-facing motion examples.

Animating an element

Bring the AnimationExt trait into scope and call with_animation on any element. You give it a stable id, an Animation describing the timeline, and an animator closure that receives the element and the eased progress delta in 0.0..=1.0:

#![allow(unused)]
fn main() {
use std::time::Duration;
use kael::{Animation, AnimationExt as _, Transformation, bounce, ease_in_out, percentage, svg};

svg()
    .size_20()
    .path(ARROW_CIRCLE_SVG)
    .with_animation(
        "spinner",
        Animation::new(Duration::from_secs(2))
            .repeat_forever()
            .with_easing(bounce(ease_in_out)),
        |svg, delta| svg.with_transformation(Transformation::rotate(percentage(delta))),
    )
}

Kael coalesces duplicate animation-frame requests from the same entity in a frame. Reduced-motion, low-power, and cancelled explicit animations jump to their completed state instead of keeping a hidden frame loop alive.

Custom display-frame effects

Use kael_ui::animations::DisplayFrameClock for custom particles, canvas effects, or springs whose state changes every presented frame. Call restart when the effect starts, pair try_arm with Window::on_next_frame, and pass the returned generation to sample inside the callback. sample returns monotonic, refresh-rate-independent delta time, so the same motion runs correctly at 60, 90, 120, or 144 Hz. It also rejects callbacks left behind by a restart and clamps the first frame after a suspended browser tab.

Schedule one callback from the effect's render path and notify its state from that callback. The next render arms the following frame. This pattern naturally stops when the effect leaves the retained tree and coalesces repeated renders before a presentation. Confetti, ParticleEmitter, and DraggableSpring use this path on both desktop and WebAssembly; none of them polls a fixed 16 ms timer.

Fixed-timestep games and simulations

UI animation should follow display time, but gameplay, physics, editors, and deterministic simulations usually need fixed updates. kael_engines provides a browser-compatible clock with bounded catch-up work and render interpolation:

#![allow(unused)]
fn main() {
use std::time::Duration;
use kael_engines::game_loop::{FixedFrameClock, FixedFrameClockConfig};

let config = FixedFrameClockConfig::from_updates_per_second(60)?
    .with_max_frame_delta(Duration::from_millis(250))
    .with_max_catch_up_steps(8);
let mut clock = FixedFrameClock::new(config)?;

let frame = clock.advance_by(Duration::from_millis(17));
for _ in frame.updates() {
    // update simulation by frame.fixed_timestep()
}
let render_alpha = frame.interpolation_alpha();
let _ = render_alpha;
Ok::<(), Box<dyn std::error::Error>>(())
}

Use tick() in a live render loop and advance_by for replay/tests. Request the next Kael animation frame only while the simulation is active. The clock clamps long display gaps, caps update steps, and exposes dropped-time telemetry so a stall cannot become an unbounded spiral of work. Pair it with canvas bulk submission (reserve_commands, fill_rects, and fill_circles) for particles, sprites, maps, and dense game/editor surfaces.

The Animation timeline

#![allow(unused)]
fn main() {
use kael::{Animation, Easing, Repeat};

Animation::new(Duration::from_millis(400))
    .delay(Duration::from_millis(100))   // wait before starting
    .easing(Easing::EaseInOut)           // pick a curve (see below)
    .repeat(Repeat::Count(3));           // Once | Count(n) | Forever
}

repeat_forever() is shorthand for repeat(Repeat::Forever), and with_easing(f) accepts any Fn(f32) -> f32 (including the helpers ease_in_out, ease_out_quint(), and bounce(inner)).

Generated motion can be inspected before it is attached to UI:

#![allow(unused)]
fn main() {
let animation = Animation::new(Duration::from_millis(400))
    .delay(Duration::from_millis(100))
    .easing(Easing::EaseInOut)
    .repeat(Repeat::Count(3));

tracing::info!(summary = animation.to_text(), "animation");
}

Use Animation::to_text(), Repeat::to_text(), and Easing::to_text() for stable timeline, repeat, and curve summaries. The summaries name curve classes such as ease-in-out, cubic-bezier, steps, or custom without logging custom callbacks or cubic-bezier control points.

Easing curves

kael::Easing is the single, canonical easing vocabulary for the workspace. It covers the full standard curve set in named variants:

VariantCurve
Easing::Linearconstant rate
Easing::EaseIn / EaseOut / EaseInOutquadratic
Easing::EaseInCubic / EaseOutCubic / EaseInOutCubiccubic
Easing::EaseInQuart / EaseOutQuart / EaseInOutQuartquartic
Easing::EaseInQuint / EaseOutQuint / EaseInOutQuintquintic
Easing::EaseInExpo / EaseOutExpo / EaseInOutExpoexponential
Easing::EaseInCirc / EaseOutCirc / EaseInOutCirccircular
Easing::EaseInBack(overshoot) / EaseOutBack(overshoot) / EaseInOutBack(overshoot)backing overshoot
Easing::EaseInElastic / EaseOutElastic / Elasticelastic
Easing::Steps(n)n discrete steps
Easing::CubicBezier(x1, y1, x2, y2)CSS-style cubic Bézier
Easing::Custom(Rc<dyn Fn(f32) -> f32>)your own
#![allow(unused)]
fn main() {
Animation::new(Duration::from_millis(600))
    .easing(Easing::EaseOutBack(1.70158));
}

For a smooth physical spring, kael_ui provides SpringValue/SpringPoint (see Springs and gestures).

kael_ui compatibility shims

kael_ui::animations::easings exposes the same curves as free fn(f32) -> f32 functions (ease_out_cubic, ease_in_back, steps(n), …). These are compatibility shims that delegate to the Easing variants above; prefer the Easing variants directly in new code. The spring, smooth_spring, and cubic_bezier helpers have no Easing variant and remain defined in that module.

Keyframes and sequences

For multi-stop transitions across common styled properties, build a Keyframes set and attach it with with_keyframes:

#![allow(unused)]
fn main() {
use kael::{Animation, AnimationExt as _, Keyframes};

div().with_keyframes(
    "pulse",
    Keyframes::new()
        .at(0.0, |k| k.opacity(0.4))
        .at(0.5, |k| k.opacity(1.0))
        .at(1.0, |k| k.opacity(0.4)),
    Animation::new(Duration::from_secs(1)).repeat_forever(),
)
}

Chain whole animations with AnimationSequence::new().then(...).then_for(duration).with_overlap(...) and drive them with with_animation_sequence. For animations you may need to interrupt, with_cancellable_animation returns an (element, AnimationHandle); call handle.cancel() to jump to the final state.

AnimationSequence::to_text() reports animation count, finite scheduled duration, whether any step repeats forever, and empty state. Keyframes::to_text(), StyledKeyframe::to_text(), MediaKeyframe::to_text(), and KeyframeTrack::to_text() report frame/property/interpolation counts and finite-value checks without logging opacity values, transform distances, media automation values, or keyframe times.

Lottie animated assets

Enable the optional native renderer first:

[dependencies]
kael = { version = "0.4", features = ["lottie"] }

Use lottie(src) for native vector animation assets instead of routing every animated visual through a WebView. Sources can be embedded resources, paths, URLs, byte buffers, or pre-decoded LottieAnimation values, and the element supports autoplay, once/loop/ping-pong playback, object-fit placement, loading content, failure fallback content, and frame prefetching:

#![allow(unused)]
fn main() {
use kael::{ObjectFit, lottie};

let loader = lottie("animations/spinner.json")
    .autoplay()
    .loop_forever()
    .object_fit(ObjectFit::Contain)
    .prefetch_frames(8);

tracing::info!(summary = loader.to_text(), "lottie element");
}

Inspect generated animated UI with LottieSource::to_text(), LottieAnimation::to_text(), LottiePlayer::to_text(), and lottie(...).to_text(). These summaries report source class, byte presence, decoded metadata, playback state, loop mode, object-fit mode, prefetch counts, and loading/fallback configuration without logging paths, URLs, embedded resource names, raw bytes, or replacement text.

Elastic scrolling

Scrollable regions — overflow_*_scroll() containers, uniform_list, and list (via ListState) — get native rubber-band overscroll automatically on macOS: content stretches past its bounds on a trackpad pull and springs back on release. Use a ScrollHandle to read or set the offset programmatically:

#![allow(unused)]
fn main() {
use kael::{ScrollHandle, point, px};

let scroll = ScrollHandle::new();
scroll.set_offset(point(px(-360.0), px(0.0)));
let current = scroll.offset();        // Point<Pixels>
let max = scroll.max_offset();        // Size<Pixels>
}

See the Astryx showcase for runnable motion and scrolling compositions.

Canvas & Graphics

Beyond the element tree, Kael gives you direct GPU drawing: an immediate-mode canvas, a vector path builder, gradients, backdrop blur, SVG, and optional Lottie playback. Everything renders through the same per-platform pipeline (Metal / DirectX 11 / Vulkan / browser WebGL2) with device-pixel snapping for crisp output at any DPI.

Visual escape-hatch ladder

When designing a graphics-heavy workflow or giving an AI agent a rendering task, choose the lowest rung that solves the problem:

NeedUse todayNotes
Product UI, dashboards, tool chromestyled div() / kael_uiBest memory and startup profile
Charts, timelines, waveform views, custom controlscanvas(...), paint_quad, paint_path, PathBuilderNative immediate-mode drawing
Game worlds, whiteboards, and large retained 2D surfacesPortableScene2d / portable_scene(...)Same bounded retained commands on native and browser renderers
Icons, diagrams, generated vector assetssvg() / PathBuilderKeep assets inspectable and themeable
Motion graphics and loaderslottie(...) with feature lottieDecodes off the UI path
Frosted or filtered subtreesbackdrop_blur(...) / effect_layer(...)Effect layers are partial CSS-filter coverage, not arbitrary shaders
Run a Kael canvas in a browserkael / kael_ui feature browserSame retained Scene through the WebGL2 renderer
External or hosted browser contentwebview(id, url)Native composition island on desktop; sandboxed iframe island in the wasm backend, with documented cross-origin limits
Golden-image or benchmark evidenceHeadlessRenderer / goldenOff-screen rendering is for tests and measurements
Public custom render target or custom shaderroadmapThe backend renderer is not yet a public arbitrary-shader API

The public graphics_capability_report() API exposes this same truth for readiness checks and agent planning. It reports full cross-backend coverage for styled elements, canvas, the portable retained 2D surface, paths, gradients, SVG, and Lottie; partial coverage for clip shapes, effect layers, and headless rendering; WebView coverage for browser graphics fallback; and roadmap status for public render targets/custom shaders.

Display density and text

Kael lays out in logical pixels and updates the backing scale whenever a native window or browser canvas moves between displays. On macOS, glyph masks use display-independent grayscale antialiasing and baselines are snapped to device pixels. Windows also uses grayscale DirectWrite coverage instead of caching panel-specific ClearType RGB stripes. This avoids stale-resolution text and RGB/BGR subpixel color fringing on scaled, rotated, or differently ordered external panels, while reducing glyph-atlas storage relative to four-channel subpixel masks.

Embed application fonts when typography is part of the product identity. kael_ui::init already registers its bundled Inter and JetBrains Mono faces. Font family, weight, layout scale, and backing resolution remain stable across screens; small rasterization differences between operating-system and browser text engines are still expected.

Canvas

For most custom graphics, use the immediate-mode canvas(size, draw) form. It records native draw commands for the current pass and lets generated code inspect composition before the commands flush into the window:

#![allow(unused)]
fn main() {
use kael::{canvas, point, px, size, stroke, Bounds};

canvas(size(px(320.0), px(180.0)), |draw, _window, _app| {
    draw.reserve_commands(6);
    draw.fill_rect(
        Bounds::new(point(px(0.0), px(0.0)), draw.size()),
        kael::rgb(0x1e1e1e),
    );
    draw.fill_rects([
        (
            Bounds::new(point(px(24.0), px(112.0)), size(px(48.0), px(40.0))),
            kael::rgb(0x3b82f6).into(),
        ),
        (
            Bounds::new(point(px(80.0), px(88.0)), size(px(48.0), px(64.0))),
            kael::rgb(0x60a5fa).into(),
        ),
    ]);
    draw.fill_circles([
        (point(px(232.0), px(64.0)), px(12.0), kael::rgb(0xf59e0b).into()),
        (point(px(268.0), px(64.0)), px(12.0), kael::rgb(0xfbbf24).into()),
    ]);
    draw.stroke_rect(
        Bounds::new(point(px(24.0), px(24.0)), size(px(120.0), px(64.0))),
        stroke(px(2.0), kael::rgb(0xffffff)),
    );

    tracing::info!(summary = draw.to_text(), "canvas draw");
})
}

For stable real-time workloads, call reserve_commands with the expected mixed command count before drawing, and use fill_rects or fill_circles for batches. The batch helpers reserve from the iterator's size hint. Circles are emitted as rounded quads, reusing the renderer's quad fast path instead of tessellating a vector path; this is the preferred route for particle systems, graph nodes, and game sprites that are geometrically circular.

DrawContext::to_text() reports queued command count, path count, quad count, filled/stroked quad counts, text count, image count, saved-state depth, and canvas size without logging text, image data, colors, or drawing coordinates. Use command_count(), path_count(), quad_count(), filled_quad_count(), stroked_quad_count(), text_count(), image_count(), state_stack_depth(), and is_empty() when agents or tests need to verify generated chart, timeline, waveform, canvas editor, or game HUD drawing.

For a scene that persists across frames, use PortableScene2d. It accepts bounded batches of solid or rounded quads, decoded-image sprites, pre-tessellated filled paths, and triangles, with affine transforms, rectangular clips, source-over opacity, typed limit failures, and transactional rollback. The default public ceilings are 100,000 commands/objects, 1,000,000 path vertices, 256 decoded image frames, 64 MiB of decoded image data, and 128 MiB of estimated retained payload. Static path transforms are baked when recorded rather than recomputed on every frame.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use kael::{Bounds, PortableScene2d, PortableSolidQuad, point, portable_scene,
           px, rgb, size};

let mut scene = PortableScene2d::new();
scene.try_reserve_commands(100_000)?;
let quads = (0..100_000).map(|index| {
    let x = (index % 500) as f32 * 3.0;
    let y = (index / 500) as f32 * 3.0;
    PortableSolidQuad::new(
        Bounds::new(point(px(x), px(y)), size(px(2.0), px(2.0))),
        rgb(0x60a5fa),
    )
}).collect::<Vec<_>>();
scene.push_solid_quads(&quads)?;

let surface = portable_scene(size(px(1_500.0), px(600.0)), Arc::new(scene));
Ok::<_, kael::PortableSceneError>(surface)
}

This is the portable game/creative-app escape hatch, not raw GPU access. Custom blend modes, user shaders, compute, depth-tested 3D, and public renderer handles return or report Unsupported and remain explicit roadmap work.

canvas also supports the lower-level two-closure form — a prepaint pass (compute layout/state, returns a value) and a paint pass (draw into the bounds). Inside paint you call window.paint_quad and window.paint_path:

#![allow(unused)]
fn main() {
use kael::{canvas, fill, quad, px, rgb, Bounds, Pixels, Window, App};

canvas(
    move |_bounds: Bounds<Pixels>, _window: &mut Window, _app: &mut App| {
        // prepaint: return any state the paint pass needs
    },
    move |bounds: Bounds<Pixels>, _state, window: &mut Window, _app: &mut App| {
        window.paint_quad(fill(bounds, rgb(0x1e1e1e)));
        // window.paint_path(path, color);
    },
)
.size_full()
}

High-fidelity pointer input and retained scenes

Use on_pointer_event for one drawing path across mouse, touch, and pen. Browser events include stable pointer id/type, primary state, changed and held buttons, pressure, tangential pressure, tilt, twist, contact geometry, cancellation, and up to 256 coalesced samples. Pointer sequences remain routed to the element that received the down event, including independent simultaneous touches. Give a surface a stable .id(...) when it rerenders during a stroke or drag so its capture set persists across frames:

#![allow(unused)]
fn main() {
use kael::{div, InteractiveElement as _, PointerPhase};

div().id("drawing-surface").on_pointer_event(|event, _window, _app| {
    if matches!(event.phase, PointerPhase::Down | PointerPhase::Move) {
        for sample in event.stroke_samples() {
            tracing::trace!(
                pointer = event.pointer_id.get(),
                pressure = sample.pressure,
                tilt_x = sample.tilt_x,
                tilt_y = sample.tilt_y,
                "stroke sample"
            );
        }
    }
})
}

Existing mouse callbacks remain source compatible. Legacy desktop mouse streams are promoted to PointerInputEvent with a stable mouse id. Windows WM_POINTER provides simultaneous touch plus pen pressure, tilt, rotation, contact geometry, cancellation, and bounded chronological history. AppKit provides tablet identity/proximity, pressure, tangential pressure, tilt, rotation, buttons, and timestamps (but no macOS desktop touchscreen or contact ellipse). Wayland wl_touch and X11 XI2.2 provide simultaneous contacts and cancellation; Wayland also reports oriented contact geometry, while tablet pressure/tilt on Linux remains compositor/device-protocol dependent. CapabilityReport exposes these per-platform boundaries. Browser touch and pen expose the full Pointer Events shape.

For large whiteboards and game scenes, SpatialIndex uses a bounded spatial hash instead of scanning every entry. SceneGraph::hit_test and SceneGraph::visible_in_rect reuse a cached index while preserving topmost order. move_node patches only the moved entry's old and new spatial cells; structural changes, visibility edits through get_mut, and hierarchy changes retain the safe lazy full-rebuild fallback. Use spatial_incremental_update_count, spatial_full_rebuild_count, and last_spatial_candidate_count to verify dynamic-scene behavior without inspecting content. Pair culling with TileDamageTracker: invalidate old and new object bounds, repaint the sorted tiles returned by take, and retain every other tile. Pathological regions promote explicitly to TileDamage::Full instead of allocating without bound.

Window::export_frame_png returns real encoded PNG bytes at device-pixel resolution from browser WebGL2, macOS Metal, Windows Direct3D 11, and the Blade renderer used by Linux and optional macOS Blade builds. GPU readback validates dimensions, row pitch, channel order, alpha representation, and a 256 MiB allocation ceiling. It honors checked content protection and returns typed WindowCaptureError variants rather than silently dropping WebView overlays or live surfaces. Platform/compositor chrome and the system cursor are outside the scene. Blade capture renders into a bounded app-owned texture before copying to shared memory, so it does not depend on swapchain copy support; it returns a typed backend error if the selected surface format is not one of the supported 8-bit RGBA/BGRA formats. Capability support remains partial because hosted/live surfaces and operating-system chrome are intentionally outside the retained scene, not because the release gate substitutes a headless renderer.

Vector paths

Build filled or stroked paths with PathBuilder, then hand the result to window.paint_path:

#![allow(unused)]
fn main() {
use kael::{PathBuilder, point, px};

let mut builder = PathBuilder::fill();        // or PathBuilder::stroke(px(2.0))
builder.move_to(point(px(50.0), px(50.0)));
builder.line_to(point(px(130.0), px(50.0)));
builder.curve_to(point(px(130.0), px(130.0)), point(px(160.0), px(90.0))); // quadratic
builder.close();
let path = builder.build()?;
}

Segment methods: move_to, line_to, curve_to (quadratic Bézier), cubic_curve_to, arc_to, and close. Stroked builders also accept dash_array / dash_offset.

Gradients

Gradients are backgrounds you pass to .bg(...):

#![allow(unused)]
fn main() {
use kael::{linear_gradient, linear_color_stop, rgb};

div().bg(linear_gradient(
    45.0,
    linear_color_stop(rgb(0xff0080), 0.0),
    linear_color_stop(rgb(0x7928ca), 1.0),
))
}

Also available: multi_stop_linear_gradient(angle, &[stops]), radial_gradient(cx, cy, radius, &[stops]), and conic_gradient(cx, cy, angle_offset, &[stops]).

Backdrop blur & frosted glass

backdrop_blur blurs whatever is painted behind an element — combine it with a translucent background for a frosted-glass panel:

#![allow(unused)]
fn main() {
use kael::{px, rgba};

div()
    .backdrop_blur(px(20.0))
    .bg(rgba(0xffffff20))
    .rounded_xl()
}

Use cached(child) when a subtree is expensive but only depends on tracked state, deferred(child) when a subtree should keep layout in-tree but paint after ancestors, and effect_layer(child) when a subtree needs native CSS-style content blur or drop shadow. Use LayerStack with LayerOptions when the app needs native in-window modal, fullscreen, or anchored overlay composition instead of a WebView-hosted DOM overlay:

#![allow(unused)]
fn main() {
use kael::{cached, deferred, effect_layer, LayerOptions, px};

let preview = cached(render_preview()).id("preview-cache");
tracing::info!(summary = preview.to_text(), "cached subtree");

let overlay = deferred(effect_layer(render_panel()).content_blur(px(8.))).with_priority(120);
tracing::info!(summary = overlay.to_text(), "deferred overlay");

let modal = LayerOptions::modal();
tracing::info!(summary = modal.to_text(), "native layer options");
}

Inspect Cached::to_text(), Deferred::to_text(), and EffectLayer::to_text() in generated graphics, overlays, previews, and inspectors. Inspect LayerAnchor::to_text(), LayerOptions::to_text(), and LayerStack::to_text() before generated modal/popover/fullscreen layer flows. These helpers report child presence, explicit cache-key presence, draw priority/class, effect combination, blur class, shadow presence, placement, backdrop/dismissal policy, and active layer counts without logging cache ids, child contents, colors, coordinates, margins, blur radii, shadow offsets, shadow colors, or geometry.

SVG

svg() renders a vector asset; text_color fills monochrome SVGs and with_transformation applies rotation/scale:

#![allow(unused)]
fn main() {
use kael::{svg, Transformation, px, rgb, size};

let icon = svg()
    .path("icons/logo.svg")
    .with_transformation(Transformation::scale(size(1.25, 1.25)))
    .size(px(24.0))
    .text_color(rgb(0x2563eb));

tracing::info!(summary = icon.to_text(), "svg");
}

Use Svg::to_text(), Transformation::to_text(), has_path(), path_len_bytes(), has_transformation(), and transformation_key() when generated icon, diagram, or vector-asset UI needs diagnostics. Summaries report path presence/byte length and coarse transform kind without logging SVG paths, asset names, transform coordinates, scale values, or rotation values.

Images and Surfaces

img(source) renders URL, embedded, file-path, cached, decoded, and custom loader image sources with native object-fit behavior:

#![allow(unused)]
fn main() {
use kael::{img, ObjectFit, StyledImage};

let poster = img("https://cdn.example.com/poster.png")
    .object_fit(ObjectFit::Cover)
    .with_fallback(|| fallback_art().into_any_element());

tracing::info!(summary = poster.to_text(), "image");
}

Common application formats—including PNG, JPEG, GIF, WebP, TIFF, BMP, ICO, TGA, HDR, PNM, farbfeld, DDS, and QOI—are enabled without pulling parallel image-processing dependencies into every Kael application. Enable the heavier formats only when the product needs them:

[dependencies]
kael = { version = "0.4", features = ["image-avif", "image-exr"] }

AVIF decoding uses the native libdav1d library and pkg-config discovery. Install both through the platform package manager, or also provide Git, Meson, and Ninja so the binding can build libdav1d from source.

The built-in resource loader rejects empty or larger-than-64-MiB encoded sources, raster or SVG dimensions above 16,384 pixels per axis, more than 256 MiB of decoded frame data, and animations above 10,000 frames. HTTP failures do not retain response bodies or expose resource locations in error messages. Use a custom ImageSource loader that returns a validated RenderImage when a controlled workload intentionally needs a different budget.

Use ImageSource::to_text(), ImageStyle::to_text(), and Img::to_text() for asset-heavy generated UI. The helpers expose source kind, resource identifier byte length, grayscale state, object-fit key, loading/fallback hook presence, and explicit cache binding without logging URLs, file paths, embedded asset names, raw bytes, decoded image IDs, pixel dimensions, or child contents.

For native image caching, scope cache providers around the subtree that owns the asset working set:

#![allow(unused)]
fn main() {
use kael::{image_cache, lru, retain_all};

let cache = lru("gallery-cache", 64);
tracing::info!(summary = cache.to_text(), "image cache policy");

image_cache(cache).child(gallery)
}

Use retain_all(id) for bounded asset sets and lru(id, max_images) for feeds, galleries, maps, and other churning image sets. Inspect RetainAllImageCacheProvider::to_text(), LruImageCacheProvider::to_text(), ImageCacheElement::to_text(), RetainAllImageCache::to_text(), LruImageCache::to_text(), and ImageCacheItem::to_text() when generated UI or agents need cache policy, entry counts, loading/loaded/error counts, capacity, capacity class, and scoped child count without logging resource identifiers, element ids, image ids, image bytes, error details, or asset names.

surface(source) renders platform-native external image buffers, such as CoreVideo pixel buffers on macOS. Use SurfaceSource::to_text() and Surface::to_text() to report source class and object-fit key without logging pixel contents or dimensions.

Lottie

Enable Kael's lottie feature to add the native decoder and renderer:

[dependencies]
kael = { version = "0.4", features = ["lottie"] }

lottie() plays Lottie/dotLottie animations, decoding frames on a background thread so the UI stays responsive:

#![allow(unused)]
fn main() {
use kael::{lottie, LoopMode};

lottie("animations/loader.json")
    .autoplay()
    .loop_forever()            // or .loop_mode(LoopMode::Loop) / .ping_pong()
}

Builders: .autoplay(), .loop_forever(), .loop_mode(LoopMode), .ping_pong(), .object_fit(ObjectFit), .prefetch_frames(n), .with_loading(|| element), .with_fallback(|| element).

See the Astryx showcase's media and visual-effects sections for complete, runnable compositions.

Game input

Kael exposes one window-level API for controller state and relative mouse input on desktop and WebAssembly builds. Enable game-input; it is also included by portable-services and browser-full.

kael = { version = "0.4", features = ["game-input"] }

Browser builds read navigator.getGamepads() directly. Native builds use gilrs for mapped controllers on macOS, Windows, Linux, and FreeBSD. Both paths produce the browser-standard four-axis and 17-button ordering when GamepadMapping::Standard is reported. Stick Y is normalized to -1 up and 1 down on every target.

Poll on display frames

Controller APIs are state snapshots, not UI events. Polling from a millisecond timer wastes wakeups, can sample the same device state repeatedly, and drifts away from rendering. Window::on_gamepad_frame coalesces onto Kael's display frame callback instead:

let polling = window.on_gamepad_frame(|sample, window, cx| {
    match sample {
        Ok(snapshot) => {
            for pad in &snapshot.gamepads {
                let steer = pad.axis(StandardGamepadAxis::LeftStickX);
                let jump = pad.button(StandardGamepadButton::South).pressed;
                update_game(steer, jump, window, cx);
            }
            GamepadFrameControl::Continue
        }
        Err(error) => {
            show_input_error(error, cx);
            GamepadFrameControl::Stop
        }
    }
});

Keep the returned GamepadFrameSubscription alive. Dropping or cancelling it prevents the already-coalesced next-frame callback from polling. For an existing game loop, call Window::gamepads() once inside its own on_next_frame callback instead.

Every snapshot is bounded:

  • at most 16 connected controllers;
  • at most 32 axes and 64 buttons per controller;
  • at most 1,024 native controller events drained per display frame;
  • controller identifiers truncated at a valid UTF-8 boundary to 256 bytes;
  • non-finite analog values become zero and all values are clamped.

event_budget_exhausted reports when a native event storm reached the per-frame drain limit. The next display frame continues the drain without blocking the current render.

Pointer lock and relative motion

Check window.game_input_capabilities() before presenting a lock affordance. In a browser, call request_pointer_lock() synchronously from a trusted click or key handler; do not await unrelated work first.

div()
    .on_click(|_, window, _| {
        if let Err(error) = window.request_pointer_lock() {
            eprintln!("pointer lock unavailable: {error}");
        }
    })
    .on_pointer_event(|event, _, _| {
        rotate_camera(event.movement.x.0, event.movement.y.0);
    })

The lifecycle is explicit:

  • Unlocked before a request;
  • Requesting while a browser or Wayland compositor decides;
  • Locked only after pointerlockchange or the Wayland locked event confirms ownership; macOS, Windows, and X11 acquire synchronously;
  • pointer_lock_error() contains the latest synchronous or asynchronous rejection. A synchronous browser exception restores Unlocked immediately and is also returned as a typed GameInputError; NotAllowedError maps to UserGestureRequired so applications can request a fresh trusted activation.

exit_pointer_lock() only releases resources owned by that Kael window. Destroying a window or losing focus releases native confinement, restores the cursor, and returns the state to Unlocked; browser window destruction removes the document listeners. PointerInputEvent::movement carries the unbounded relative delta on every supported backend, while absolute position remains a stable in-window coordinate.

Native implementations use CoreGraphics cursor disassociation on macOS, Raw Input plus client-area confinement on Windows, and XI2 raw motion plus an X11 pointer grab on X11. Wayland support is intentionally runtime-conditional: the active seat must expose a pointer and the compositor must advertise both pointer-constraints-v1 and relative-pointer-v1. A Wayland compositor may defer or revoke the lock; compositors lacking either protocol report GameInputAvailability::Unsupported. Applications keep one code path by branching on GameInputCapabilities::pointer_lock.

The maintained GTK4 WebView host binds those protocols on GTK's exact GDK-owned Wayland connection and uses the active GDK surface and pointer. On X11 it uses XI2 raw motion plus a pointer grab tied to the GTK surface XID. Relative motion, focus-loss cleanup, cursor restoration, the retained GSK scene, and WebKitGTK 6 children therefore share one window lifecycle on either compositor. Wayland motion is queued until after protocol dispatch releases backend state, preventing input callbacks from re-entering the native host borrow.

Release proof

Run the deterministic retained-window smoke:

bash scripts/verify-browser-game-input-smoke.sh

On a macOS desktop host, exercise the real native cursor and focus-loss cleanup path with:

cargo run -p kael --example native_pointer_lock_smoke --no-default-features

The Linux compositor-backed gates verify Wayland protocol discovery and the GTK4 X11/XWayland selection, raw handles, pointer-lock backend, and an event-driven idle interval with no permanent frame clock:

bash scripts/ci/run-linux-webview-wayland-gtk4.sh
bash scripts/ci/run-linux-webview-xwayland.sh

It packages the real Wasm example and verifies capability discovery, standard axis/button mapping, input bounds, pointer lock/change/release/error lifecycle, typed synchronous DOM exceptions, relative movement, and display-frame polling in headless Chromium. The mock device and pointer-lock provider make CI deterministic. Products should also acceptance-test real controller visibility and pointer-lock policy in the exact browser embedding modes they ship.

Gestures

Kael provides built-in gesture recognizers for touch and pointer interactions. Before relying on tablet-style input, check the platform capability report:

#![allow(unused)]
fn main() {
let input = CapabilityCheck::new()
    .require(PlatformFeature::PrecisionPointerInput)
    .prefer_available(PlatformFeature::GestureInput)
    .prefer_available(PlatformFeature::TouchInput)
    .prefer_available(PlatformFeature::PenInput)
    .evaluate(&CapabilityReport::current());
}

Pointer, scroll, and magnify gestures are the portable baseline today. Direct touch contact streams and pen pressure/tilt metadata are reported separately so apps can provide mouse/keyboard fallbacks when a backend does not expose them.

Pan gesture

Detect drag/pan movements with velocity tracking:

#![allow(unused)]
fn main() {
use kael::gesture::PanGesture;

let pan = PanGesture::new()
    .min_distance(px(5.0))
    .on_start(|position, _window, _cx| { /* drag started */ })
    .on_update(|delta, velocity, _window, _cx| { /* dragging */ })
    .on_end(|velocity, _window, _cx| { /* drag ended */ });
}

Swipe gesture

Detect directional swipes:

#![allow(unused)]
fn main() {
use kael::gesture::SwipeGesture;

let swipe = SwipeGesture::new()
    .on_swipe(|direction, _window, _cx| {
        match direction {
            SwipeDirection::Left => { /* swipe left */ },
            SwipeDirection::Right => { /* swipe right */ },
            SwipeDirection::Up => { /* swipe up */ },
            SwipeDirection::Down => { /* swipe down */ },
        }
    });
}

Pinch gesture

Zoom/scale with pinch-to-zoom or Ctrl+scroll:

#![allow(unused)]
fn main() {
use kael::gesture::PinchGesture;

let pinch = PinchGesture::new()
    .on_pinch(|scale, center, _window, _cx| {
        // scale: f64 (1.0 = no change, >1 = zoom in, <1 = zoom out)
        // center: Point<Pixels> (pinch center point)
    });
}

Drag and drop

File drop (from OS)

#![allow(unused)]
fn main() {
let filter = FileDropFilter::video().max_files(1);

div()
    .id("drop-zone")
    .can_drop_external(filter.clone())
    .on_external_drop(move |data, _window, _cx| {
        if let Some(paths) = data.accepted_paths_by(&filter) {
            for path in paths {
                /* import or open path */
            }
        }
        if let Some(text) = data.text_value() {
            /* handle dropped text */
        }
        for url in data.urls() {
            /* handle dropped URL */
        }
    })
}

Operating-system file drops are translated into Kael's typed drag/drop system as ExternalPaths for file-only payloads and ExternalDropData when text or URLs are present. Use can_drop_external(filter) and on_external_drop(...) to handle both shapes through one browser-like payload. Presets are available for common file cases: FileDropFilter::single_file(), .images(), .audio(), .video(), and .media().

For browser-style integrations that can carry text or URLs alongside files, normalize to ExternalDropData:

#![allow(unused)]
fn main() {
let data = ExternalDropData::from_paths([path])
    .with_text("Dropped label")
    .with_url("https://example.com/item");

if let Some(paths) = data.accepted_paths_by(&FileDropFilter::images()) {
    /* import image paths */
}

let from_active_drag = ExternalDropData::from_drag_value(value);

let from_uri_list = ExternalDropData::from_uri_list(
    "file:///tmp/poster.png\nhttps://example.com/item\n",
);
let from_text = ExternalDropData::from_plain_text("https://example.com/item");
}

Native OS file-only drops still emit ExternalPaths for compatibility. Drops that carry plain text or URLs emit ExternalDropData on macOS, Windows, and Linux text/uri-list paths. Use ExternalDropData for custom platform integrations, WebView bridge messages, and tests that need DataTransfer-like files / text / urls payloads.

Sortable reordering

See SortableList for drag-to-reorder within lists.

Scroll events

#![allow(unused)]
fn main() {
div()
    .id("canvas")
    .on_scroll_wheel(|event, _window, _cx| {
        // event.delta: ScrollDelta (Pixels or Lines)
        // event.modifiers: Modifiers (detect Ctrl for zoom)
    })
}

Benchmarking Evidence

Do not claim Kael is lighter than a baseline from architecture alone. Measure the same workload on the same machine, then compare the results.

Kael exposes product-shaped benchmark scenarios and metrics through kael::benchmark:

#![allow(unused)]
fn main() {
use kael::{
    BenchmarkHarness, BenchmarkMeasurement, BenchmarkMetric, BenchmarkSampleApp,
    BenchmarkSamplePair, BenchmarkSampleRuntime, BenchmarkScenario, BaselineComparisonReport,
    MetricUnit,
};
use std::time::Duration;

let baseline_results = load_baseline_results()?;

let mut harness = BenchmarkHarness::new();
harness.run(BenchmarkScenario::Dashboard, "kael", |measurements| {
    run_dashboard_workload();
    measurements.push(BenchmarkMeasurement {
        metric: BenchmarkMetric::IdleMemory,
        value: measure_idle_memory_mb(),
        unit: MetricUnit::Megabytes,
        elapsed: Duration::from_secs(5),
    });
});

let interactions = BenchmarkScenario::Dashboard
    .workload_spec()
    .required_interactions;
let sample_pair = BenchmarkSamplePair::new(
    BenchmarkSampleApp::builder(
        BenchmarkSampleRuntime::Baseline,
        BenchmarkScenario::Dashboard,
        "Baseline dashboard sample",
    )
    .source("samples/baseline/dashboard")
    .run_command("npm run bench:dashboard")
    .interactions(interactions.clone())
    .build_checked()
    .unwrap(),
    BenchmarkSampleApp::builder(
        BenchmarkSampleRuntime::Kael,
        BenchmarkScenario::Dashboard,
        "Kael dashboard sample",
    )
    .source("samples/kael/dashboard")
    .run_command("cargo run --release -p dashboard_sample -- --bench")
    .interactions(interactions)
    .build_checked()
    .unwrap(),
);

let report = BaselineComparisonReport::generate_with_sample_pairs(
    &baseline_results,
    harness.results(),
    &[sample_pair],
    Some("trace.json".into()),
);

println!("{}", report.summary());
}

Use the same scenario name for both result sets so metrics line up. Each scenario exposes a workload contract:

#![allow(unused)]
fn main() {
let spec = BenchmarkScenario::Dashboard.workload_spec();
println!("required metrics: {:?}", spec.required_metrics);
println!("required interactions: {:?}", spec.required_interactions);

let issues = spec.validate_result(&kael_result);
assert!(issues.is_empty(), "missing benchmark evidence: {issues:?}");
}

Available scenarios include chat, IDE/workspace, document, canvas/design tool, video editor, dashboard, messaging, and media-control workloads.

Important metrics for resource and performance claims:

ClaimMetrics
Starts fasterColdStart, WarmStart, FirstInteractiveFrame
Uses less memoryIdleMemory, MemoryGrowth
Stays idleLongSessionCpu, IdlePower, WakeupsPerSecond
Feels responsiveInputLatency, ScrollLatency, frame-time percentiles
Handles real UI loadScenario coverage plus AssetCacheHitRate

BaselineComparisonReport classifies each shared metric as a Kael win, baseline win, or tie while preserving the raw numbers. Lower-is-better metrics such as memory and latency are handled differently from higher-is-better metrics such as cache hit rate. The report also records evidence_issues when either side is missing a counterpart result for the same scenario, when either side is missing required metrics, when either side supplies duplicate results for the same scenario, when baseline and Kael results were captured under different hardware or OS conditions, when a compared scenario lacks matching baseline/Kael sample descriptors, or when a sample omits required interactions; do not make parity claims until those are resolved.

For CI, keep using CiReport and RegressionThresholds to compare a Kael candidate against a previous Kael baseline. Use BaselineComparisonReport for product/positioning evidence against a baseline sample app.

Browser artifact size

Release packaging runs pinned Binaryen 132 at -O3, then scripts/verify-browser-artifact-budget.sh rejects the maintained retained-scene proof above 12 MiB raw Wasm, 5 MiB gzip Wasm, or 100 KiB JavaScript glue. These are regression ceilings, not a promise that every application has the same size: enabled features, fonts, codecs, and product assets remain app-owned. The workspace release profile also uses fat LTO and one codegen unit; measure clean release-build time separately from runtime and transfer performance.

Dynamic SceneGraph probe

Large games and whiteboards have a different hot path from retained interface layout: objects move repeatedly while the scene population stays stable. Run the maintained release probe with:

cargo run --release -p kael --example scene_graph_move_query_probe

The probe builds and indexes exactly 100,000 nodes, then performs 10,000 cross-cell move_node plus point-query operations. It requires:

  • zero full spatial-index rebuilds during bounds-only movement;
  • exactly 10,000 incremental spatial updates;
  • no more than two candidates in any representative point query; and
  • the complete move/query phase to finish within two seconds.

The two-second ceiling is a deliberately generous regression budget for shared CI hosts, not a frame-time or cross-framework performance claim. Preserve the raw elapsed time and environment when publishing results. Correctness tests also cover z-order retention, moves between normal and oversized spatial lanes, and the full-rebuild fallback when cached entry metadata is unavailable.

Platform APIs

Kael exposes native desktop services through the core runtime and focused support crates. Availability varies by operating system, installed desktop services, permissions, packaging, and compile-time features.

Capability checks

Use CapabilityReport::current() before making a platform integration a hard requirement:

#![allow(unused)]
fn main() {
use kael::{CapabilityCheck, CapabilityReport, PlatformFeature};

let report = CapabilityReport::current();
let readiness = CapabilityCheck::new()
    .require(PlatformFeature::GpuRendering)
    .prefer_available(PlatformFeature::GlobalHotkeys)
    .prefer_available(PlatformFeature::StatusBarItem)
    .evaluate(&report);

if let Some(reason) = readiness.required_failure_summary() {
    return Err(reason.into());
}
}

Use require for features that must be fully supported and require_available only when the application has implemented the setup and fallback paths for Partial or RequiresInit.

Compile-time features

The default kael build enables the native window backends but keeps WebView support off. Applications that embed web content can opt into Wry and the platform WebView dependencies:

[dependencies]
kael = { version = "0.4", features = ["webview"] }

The core exports the HttpClient interface without selecting a transport. Enable http-client for Kael's Reqwest adapter, supply an app-owned transport, or use kael_ui's default http feature, which enables the adapter for remote assets:

[dependencies]
kael = { version = "0.4", features = ["http-client"] }

Use wayland instead of, or alongside, x11 as required. Other product services are opt-in through core features or their focused crates. Enable auto-update for Kael's signed-feed, checked-download, and platform-installer pipeline, and lottie for native Lottie/dotLottie playback. Leaving these features off keeps their implementation dependencies out of the application.

Service overview

AreaAPI or crateNotes
Windows, displays, input, clipboard, menuskaelNative per-OS backends
Accessibilitykael + AccessKit bridgesSemantics still require correct app markup
Global hotkeys and traykaelLinux support depends on X11 or desktop portals/SNI
Basic notificationskaelPortable async delivery is in kael_notifications; browser delivery is permission gated
Storage and cachekael_storage, kael_cacheApp-owned data and bounded caches
Credentialskael_secretsKeychain, Credential Manager, or Secret Service
Documents, Office, and PDFkael_document, kael_office, kael_pdfLifecycle, autosave, portable OOXML packages, and PDF byte operations
Sharingkael_share or core share featureNative share services or the user-activated browser Web Share picker
Networkingcore http-client, kael_http_client, kael_netBounded HTTP plus one native/browser WebSocket client; SSE remains descriptor-only
Diagnosticskael_diagnosticsBounded logs, metrics, reports, crash helper
Updateskael_release and core auto-update featureSigned feeds and product-controlled installation
Media and audiokael-media, kael_audio, engine cratesNative streams plus asynchronous bounded browser Web Audio; codecs and engines remain opt-in
Web compatibilitycore webview featureWebView2 on Windows, WebKitGTK on Linux

The portable WebSocket client is documented in Realtime Networking. Opening a socket requires an explicit checked host policy; the core AppRealtimeConnection adapter applies its NetworkPolicy again at the live side-effect boundary.

Notifications

Basic OS notification delivery and action support live in kael. The kael_notifications adds validated categories, immediate delivery, native-only in-process interval scheduling, cancellation, event subscriptions, and typed permission/backend errors. Use NotificationCenter::schedule_local_async as the one-codebase call. Browser delivery uses the Notification API after an asynchronous permission decision; it does not fake durable scheduling, service-worker action buttons, push, named sounds, or badge counts.

Packaged Windows applications must register an AppUserModelID in their installer and call set_windows_app_user_model_id before delivering toasts. Push tokens, calendar triggers, location triggers, and macOS text-input actions are not implemented by Kael 0.4; product-specific push credentials and delegates remain the application's responsibility.

Sharing

kael_share validates text, URL, image, and file payloads before platform handoff. Query ShareSheet::platform_support() before presenting destinations. macOS supports the broadest outbound sheet. Windows and Linux currently provide narrower mail/clipboard paths; Windows file/image DataTransferManager support and share-receiver registration are not implemented. In browsers, ShareSheet::show_portable uses navigator.share during a transient user activation and supports bounded ShareFile bytes and images when navigator.canShare accepts them. Browser policy, cancellation, unavailable APIs, unsupported payloads, and lost activation are separate typed errors.

Printing

Window::print, Window::show_print_dialog, and Window::print_checked now execute native PrintJob content on every desktop backend. Fills, rounded fills, strokes, lines, single-line and wrapped text, and retained images (including fit, clipping, selected frame, and opacity) share a bounded portable renderer. Landscape jobs expose rotated page/content dimensions to the render callback so the same coordinates are not clipped when the output backend rotates the paper.

Windows shows the native common print dialog or silently targets the configured default printer, then spools checked page rasters through the selected printer DC with cleanup on every failure. Linux dialog printing uses the XDG desktop Print portal and passes an exact PDF file descriptor; silent Linux printing uses an absolute system lp/lpr client path. Accordingly, Linux printing remains a partial runtime capability: dialog mode needs a portal backend, and silent mode needs CUPS plus a default printer. Browser printing always shows browser-owned UI because the web platform does not permit silent printer selection.

Capture and media

Use App::is_screen_capture_supported() and query sources at runtime. Permission approval and source availability can change after launch. A checked AppWindowCaptureRequest is only a descriptor; Kael 0.4 does not expose a native app-window screenshot backend through that type.

The portable CaptureManager::with_default_backends() API supports screen and window capture on desktop and browser builds. In a browser, enumeration returns a privacy-safe synthetic picker entry and start must run directly inside a trusted click or key activation. The browser then chooses the exact display, window, or tab asynchronously. A session first reports Starting, then Running or Error; inspect CaptureSession::last_error() for picker denial, lost activation, or setup failure. Browser frames are bounded RGBA8 buffers and requested video audio is rejected explicitly—compose microphone input through kael_audio when recording or calling.

Media engines are separate crates so applications that only need UI primitives do not pull codecs and editing infrastructure into their dependency graph. Browser live mixing, device enumeration, and permission-gated microphone capture are documented in Browser Audio. The capability report marks microphone capture RequiresInit only when the audio feature is compiled, and marks Kael's lightweight stereo spatial scene Partial; neither status claims synchronous permission, HRTF, room processing, or non-default browser speaker routing.

WebView boundaries

Treat every WebView as an external-content boundary:

  • restrict navigation and new-window behavior;
  • allow only required permissions;
  • validate messages crossing the bridge;
  • choose persistent or ephemeral storage deliberately;
  • keep native app state outside browser storage where possible.

WebViews are native composition islands, not Kael scene primitives. Rectangular content-mask bounds, translation, visibility, and inherited opacity are applied to the native host. Scale, rotation, or skew hides the host because resizing a native WebView would reflow the page instead of reproducing the GPU transform. Native surfaces remain above Kael's GPU scene, so applications should place modals and popovers outside a visible WebView region or hide the island while presenting overlapping chrome.

Omitting storage_key creates an incognito/non-persistent profile. Supplying a key creates stable isolated profiles on Windows and Linux and on macOS 14 or newer; older macOS versions retain persistence but share the default data store. Windows and Linux additionally namespace profiles by executable path so unrelated Kael applications cannot share a profile key; moving the executable starts a fresh profile. Use native_permission_policy for the browser engine's actual permission boundary. on_permission_request remains a page-level JavaScript preflight for app-owned browser APIs and is not a native security boundary. Native coverage follows the engine. The detailed native_permission_request_policy receives WebView2's requesting origin and user-gesture state on Windows (frame identity remains unknown), the current top-level origin as an explicitly labelled approximation on WebKitGTK, and the requesting origin plus main/subframe identity for WKWebView camera and microphone requests. Permission decisions are not persisted by Kael's WebView2 hook, so the application policy remains authoritative on every request. The legacy native_permission_policy intentionally discards this context; use it only for policies that are safe by permission kind alone.

Linux uses one maintained child-host path across X11, XWayland, and native Wayland. The portable webview feature selects GTK4 + WebKitGTK 6: GTK owns the top-level surface, Kael renders its retained scene through GSK, and WebKit views are siblings in the same widget hierarchy. It is not a detached overlay. Bounds, clipping, visibility, focus, scale changes, IME, touch, clipboard, file drop, window lifetime, native pointer lock, and raw Wayland/X11 handles therefore belong to the same production window. webview-gtk4 and webview-wayland-gtk4 are compatibility aliases for Linux-only manifests.

Automatic selection follows GDK_BACKEND ordering when valid display sockets exist, then prefers Wayland when both displays are available. Set KAEL_LINUX_BACKEND=x11 or wayland to override it. The old GTK3/WebKitGTK 4.1 host is not shipped; the deprecated webview-legacy-gtk3 spelling redirects to the same maintained GTK4/WebKitGTK 6 host.

When the webview feature is disabled, the capability report returns SupportLevel::Disabled rather than claiming the OS backend is usable. With a native host feature enabled, the report returns SupportLevel::Partial because WebViews are rectangular native islands and cannot participate in arbitrary retained-scene transforms. A raw Wayland or X11 build without the GTK4 WebView host, and every headless backend, reports WebViews as unsupported. This lets an application reject a hard requirement before it opens a window. The implementation contract and acceptance gates are documented in Linux WebView hosting.

Operation-level WebView capabilities

CapabilityReport::current() answers whether a WebView host is available. WebViewCapabilityReport::current() is the release-grade operation matrix for the selected backend. It reports each of composition, focus, navigation and policy, load state, history, reload/stop, zoom, find, print, downloads, IPC, custom protocols, developer tools, cookies, request headers, profile isolation, user agent, JavaScript, native permission policy, and drag/drop as Full, Partial, Unsupported, or Disabled, with an exact limitation note.

Important current boundaries are explicit:

  • macOS, Windows, and Linux X11/XWayland preserve a live document when declarative focus changes; focus no longer recreates the host or loses history/profile state.
  • Windows and Linux provide isolated named profiles. WKWebView named profile isolation requires macOS 14 or newer; older macOS versions share the persistent default data store.
  • macOS WKWebView, Windows WebView2, and both Linux WebKitGTK hosts report CustomProtocols as Full. Registered app-owned routes preserve status, MIME type, response headers, and bounded body bytes for main documents and subresources. Browser iframes still report this operation as unsupported because web pages cannot register arbitrary URL schemes; use HTTP(S), blob:, data:, or inline HTML for browser-hosted islands.
  • Browser iframe host navigation policy covers initial/declarative/controller loads, but browser security prevents the parent from synchronously vetoing an arbitrary cross-origin page's self-navigation, popup, or download.

Use WebViewCapabilityReport::for_backend(...) in release tooling to render a deterministic cross-platform matrix without pretending the current machine is evidence for every engine.

Unsupported is an actionable result

Kael reports native push registration, geolocation, spellchecking, hardware device discovery/I/O, file-promise drag sources, and app-window snapshot backends as unsupported in 0.4. Applications can hide the workflow, provide an owned integration, or keep a scoped WebView fallback. Do not treat the presence of a request builder as backend support.

Native Capability Bridge

Kael's primary application surface combines Rust, native windows, and a retained GPU-rendered UI tree. A WebView is an explicit compatibility island for a dependency that is genuinely web-shaped, such as an OAuth page, payment flow, map, hosted document, or vendor widget.

Choose the smallest layer

NeedStart with
Runtime, rendering, input, text, layout, and windowskael
A custom design systemkael primitives and Styled
Ready-made, brandable controlskael_ui
Product servicesthe focused kael_* support crates
A browser-owned surfaceKael's optional webview feature

kael never depends on kael_ui. Applications can build their entire visual language on the primitive crate or use only the component families they want.

Treat capability reports as runtime evidence

#![allow(unused)]
fn main() {
use kael::{CapabilityReport, PlatformFeature};

let report = CapabilityReport::current();

if report.is_supported(PlatformFeature::GlobalHotkeys) {
    // Enable the primary native workflow.
} else if report.is_available(PlatformFeature::GlobalHotkeys) {
    // Explain setup or platform limitations and retain a fallback.
} else {
    // Hide the workflow or use a deliberate alternative.
}
}

Full means Kael exposes a usable backend without a documented fallback. Partial and RequiresInit require the caller to handle the note and setup. Unsupported means a descriptor or OS API may exist, but Kael does not provide the native operation. Disabled means the required Kael feature was not built.

Do not infer support from a builder type. Checked descriptors validate intent; they do not substitute for an OS backend.

Native-first decision rule

  1. Use native primitives for app chrome, editors, navigation, data surfaces, commands, menus, files, background work, and long-lived product state.
  2. Use a focused support crate for storage, secrets, documents, diagnostics, networking, notifications, sharing, media, and release services.
  3. Query the capability report for platform-dependent workflows.
  4. Use a WebView only for a scoped web dependency, with explicit navigation, permission, storage, and bridge policy.

Important 0.4 boundaries

The following are not native batteries in Kael 0.4 and are reported as unsupported: push-registration backends, native geolocation, USB/HID/serial/ Bluetooth discovery and I/O, outbound file-promise drag sources, app-window snapshot backends, and native spellchecking. Applications may supply their own integration without pretending Kael completed it.

Outbound sharing is feature-gated and platform-dependent. macOS has the broadest destination support; Windows and Linux currently provide narrower mail/clipboard handoffs. Registering an app as a share receiver is not yet implemented. Browser builds use the transient-activation-gated Web Share API for bounded text, URLs, images, and in-memory files; the browser owns the destination list, and PWA share-target registration remains product work. Browser notifications similarly require an asynchronous permission decision and provide immediate page-created delivery, not a fake durable scheduler or service-worker push/action backend. WebView support is reported as disabled when the webview feature is absent and partial when enabled because it is a native composition island with platform/runtime constraints rather than a GPU scene primitive.

Optional agent planning metadata

The agent-tools feature exposes Kael's structured desktop-capability planning metadata. It is off by default because those types describe and audit implementation work; normal applications should not pay to compile them.

[dependencies]
kael = { version = "0.4", features = ["agent-tools"] }

Agents do not need this feature to build Kael applications. The public Rust API, crate documentation, concise llms.txt, and Astryx source are the primary references.

Readiness rule

A capability is production-ready only when the native operation exists, errors are actionable, platform variance is represented, and CI exercises the relevant target. A descriptor, roadmap entry, or showcase rendering is not sufficient evidence by itself.

Browser and WebAssembly

Kael runs the same application model on desktop and in the browser. State, layout, components, painting, virtualization, animation, and accessibility stay in Rust. The browser backend renders the retained scene into WebGL2.

This is a real application target. It is not a DOM rewrite and it is not a WebView around the desktop build.

What works

The browser backend connects these inputs to Kael's normal event paths:

Input or serviceBrowser path
Pointer and buttonsPointer events and pointer capture
Trackpad and mouse wheelWheel events
Keyboard and shortcutsKeyboard events
Text and IMEInput and composition events
Copy, cut, and pasteClipboard events with bounded payloads
File intakeFile picker and drag and drop bytes
AnimationrequestAnimationFrame
AccessibilityA synchronized semantic DOM mirror
Embedded web contentManaged iframe layers

Kael tracks the device pixel ratio, refresh rate, visibility, and WebGL context. Moving a page between displays updates the backing scale. Hidden or idle windows stop requesting frames. A restored WebGL context receives a complete retained frame without resetting application state.

Do I need JavaScript?

No JavaScript is needed for normal Kael UI, input, state, animation, or canvas work. kael web build generates app.js. That file loads the Wasm module and connects it to the browser.

Write JavaScript only when the product needs a browser feature that Kael does not expose, such as:

  • a service worker
  • a third party DOM widget
  • a browser vendor SDK
  • an unwrapped Web API
  • custom host page integration

Keep that code at the product boundary. The application itself can remain one Rust codebase.

Build and run

Install the pinned packaging tools once:

rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.122 --locked
npm install --global binaryen@132.0.0

Run the development build:

kael web serve --debug

Build the optimized site:

kael web build

The default output is:

dist/web/
├── index.html
├── app.js
└── app_bg.wasm

Use --package <name> or --bin <name> in a Cargo workspace. Use --out-dir <path> to change the output directory. Use --html <file> for a source-owned host page and --assets <directory> for product web assets. kael web serve also accepts --port <number> and --no-open.

See Web build and deployment for the complete host contract.

Keep dependencies portable

A portable project selects native and browser features by target:

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
kael = { version = "0.4", features = ["runtime_shaders"] }
kael_ui = "0.4"

[target.'cfg(target_arch = "wasm32")'.dependencies]
kael = { version = "0.4", default-features = false, features = ["browser"] }
kael_ui = { version = "0.4", default-features = false, features = ["browser"] }

Use CapabilityReport::current() before a workflow depends on an operating system service. This keeps the main view and state shared while making the fallback explicit.

Files and documents

Use App::prompt_for_files or App::show_open_files to receive ExternalFile values. They contain a safe name, optional MIME type, and bytes. Desktop selections can also contain a native path. Browser selections never invent one.

Use App::save_file_bytes for generated files. Desktop opens a Save dialog. The browser starts a download. DOCX, XLSX, PPTX, PDF, and project bytes can use the same parser and exporter on both targets.

Browser file intake is limited to 256 MiB per file and 512 MiB per selection or drop. Failed files remain visible with an error.

Browser boundaries

The browser cannot provide every desktop service. It does not provide native paths, subprocesses, global hotkeys, status items, keychain access, detached OS windows, auto update, system idle state, or unrestricted device access.

Notifications, microphone capture, and screen capture require browser permission and an enabled Kael feature. Sharing is partial. Push, durable notification actions, and share targets need product owned PWA or service worker code.

Secondary Kael windows are surfaces inside the page. They cannot detach to another display or become taskbar or dock windows.

See What remains for the current framework gaps.

Release evidence

The maintained browser suite covers Chromium, Firefox, and WebKit. The current release tested baseline is Chromium 151, Firefox 153, and WebKit 26.5. Those are the versions pinned by the release matrix, not a claim that older browsers work.

The smoke app renders one million logical table rows with no more than 64 rows mounted. It tests scroll input, direct jumps, IME, clipboard, retained damage, component animation frames, WebGL loss and restoration, and framebuffer output. Hardware and software rasterizer results are reported separately.

Read Benchmarking evidence for limits and reports, Browser workers for bounded background work, and Browser audio for the Web Audio path.

Web build and deployment

kael web build creates a static site. A server runtime is not required.

Build

Install the exact tools expected by Kael 0.4:

rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.122 --locked
npm install --global binaryen@132.0.0

Then build:

kael web build

Release builds run wasm-opt -O3. Use kael web build --debug for a faster local build without release optimization.

Useful options:

OptionPurpose
--out-dir <path>Change dist/web
--html <file>Package a source-owned host page
--assets <directory>Copy product web assets into the output
--package <name>Select a workspace package
--bin <name>Select a binary target
--port <number>Change the development server port
--no-openServe without opening a browser

The last two options apply only to kael web serve.

Deploy

Upload the full output directory and preserve its relative paths:

dist/web/
├── index.html
├── app.js
└── app_bg.wasm

The host must:

  • serve app_bg.wasm as application/wasm
  • allow JavaScript modules
  • serve all three files from the same origin by default
  • use HTTPS for permission based browser APIs; localhost is valid for development

Deploy the three files together. Their names are stable, not content hashed. Do not apply immutable caching unless your own build step fingerprints them. Revalidate the HTML, JavaScript, and Wasm files after a release.

If the app uses history based routes, configure the host to fall back to index.html.

Use a custom host page

Keep a custom host page in the source tree and pass it to either build command:

kael web build --html web/index.html --assets web/assets

The HTML file becomes dist/web/index.html. Asset directory contents keep their relative paths. Kael rejects asset symlinks and reserved root files named index.html, app.js, or app_bg.wasm, so product assets cannot silently replace the generated application.

The host page must contain a canvas with the expected id and load the generated module:

<canvas id="blade" aria-label="Kael application"></canvas>
<script type="module">
  import init from "./app.js";
  await init({ module_or_path: "./app_bg.wasm" });
</script>

The default page contains inline style and module code. A strict Content Security Policy needs a custom page with external files, a nonce, or matching hashes.

The CLI does not fingerprint deployment assets or generate a service worker. Add those product policies after kael web build when required.

Verify the deployment

Open the deployed page and confirm:

  1. app.js loads as a JavaScript module.
  2. app_bg.wasm returns application/wasm.
  3. The browser creates a WebGL2 context.
  4. Pointer, keyboard, text input, and scrolling reach the application.
  5. Permission based workflows run from a user action.

No product JavaScript is needed for normal Kael interactivity. See Browser and WebAssembly for the runtime contract and limits.

Browser workers

Kael maps its typed native worker contract onto dedicated browser Web Workers. The host and worker exchange the same WorkerRequest, WorkerResponse, WorkerProgress, WorkerError, IpcMessage, and bootstrap handshake types on both targets. The browser transport adds a versioned envelope and transfers a framed Uint8Array rather than accepting arbitrary JavaScript objects.

Use the asynchronous APIs in shared desktop/browser source:

let info = ProcessInfo::worker(ProcessId(0), "sheet-recalculation")
    .executable("./sheet_worker_bootstrap.js");
let mut host = WorkerHost::with_temp_dir();
let worker = host.spawn_worker(ProcessClass::Worker, info)?;

worker.health_check_async().await?;
let output: RecalculationResult = worker.request_async(input).await?;
host.terminate_worker(worker.id())?;

Desktop calls wait on the existing socket or named-pipe transport using a blocking-pool task. Browser calls await postMessage responses and never block the JavaScript UI thread. The legacy synchronous request, health_check, and worker-pool request methods remain unchanged on desktop; browser builds return an explicit error directing the caller to the async form.

BackgroundExecutor::spawn cannot automatically move an arbitrary Send + 'static Rust future into a Web Worker. A browser worker instantiates a separate WebAssembly heap, so a closure containing Rust pointers is not transferable. Use BackgroundExecutor::spawn_worker_request for a serializable, typed CPU handoff. This schedules only the message-wait future on the UI event loop while the registered worker handler performs the expensive work.

Worker bootstrap

A module-worker bootstrap initializes the same generated WebAssembly module:

import init from "./my_app.js";

const startupMessages = [];
const bufferStartupMessage = (event) => startupMessages.push(event);
self.addEventListener("message", bufferStartupMessage);

await init();

self.removeEventListener("message", bufferStartupMessage);
for (const event of startupMessages) {
  self.onmessage?.call(self, event);
}

Buffering the startup window makes the handshake independent of how long the generated JavaScript and WebAssembly take to initialize.

The application's main or exported worker entry detects DedicatedWorkerGlobalScope, calls WorkerClient::connect_from_env, and installs its handler with WorkerClient::run. The host supplies the bootstrap module URL through ProcessInfo::executable. Module workers are the default; WorkerHost::classic_worker_scripts is available for a deliberately classic script build.

Bounds and browser boundary

  • The wire protocol rejects an unsupported version, malformed/trailing frames, non-Uint8Array messages, and payloads over the shared 16 MiB IPC limit.
  • Each worker retains at most 1,024 pending request callbacks. Async requests default to a 30-second response timeout; bootstrap defaults to 10 seconds.
  • Capabilities are validated and must be granted exactly. The default is only worker:execute; configure both WorkerHost::with_capabilities and WorkerClient::connect_with_capabilities to expand it.
  • javascript: and data: worker URLs are rejected. Browser same-origin/CORS, Content Security Policy, and module-worker policy still apply.
  • Browser process arguments, environment variables, working directories, and automatic restart policies have no faithful Web Worker equivalent and return explicit errors. Recreate a failed worker from its Exited event.
  • Cancellation is cooperative. A synchronous CPU handler cannot observe a queued cancel until it yields or returns; split interruptible algorithms into bounded requests.
  • This bridge does not require shared memory or cross-origin isolation. That keeps deployment simple, but requests cross an explicit serialization boundary instead of sharing Rust references.

Maintained release probe

Build the independent worker probe without modifying the retained-scene smoke:

bash scripts/build-browser-worker-smoke.sh
python3 -m http.server 8000 --directory target/browser-worker-smoke

Or run the build, headless browser, and marker checks together:

bash scripts/verify-browser-worker-smoke.sh

The probe validates handshake/health, typed progress, a one-million-item CPU request, a bounded 25 ms worker CPU interval during which the UI event-loop timer must fire, the explicit synchronous-browser error, and termination. A headless CI gate should require all of these final DOM markers:

data-kael-worker-probe="passed"
data-kael-worker-protocol="1"
data-kael-worker-items="1000000"
data-kael-worker-progress="passed"
data-kael-worker-ui-thread="responsive"
data-kael-worker-terminated="passed"

The CI verifier keeps headless Chrome alive on real wall-clock time and waits for an equivalent __kael_worker_pass__=1 HTTP beacon. Avoid Chrome virtual time for this probe: it can advance the host's bounded request timer while the independently scheduled worker is still loading JavaScript and WebAssembly.

Browser Audio

kael_audio keeps the Rust mixer, DSP sources, offline rendering, playlist, session, and lightweight spatial-scene APIs available on desktop and wasm32-unknown-unknown. Browser I/O is asynchronous where the platform is asynchronous: output graph construction installs an AudioWorklet, device enumeration awaits enumerateDevices, and capture awaits getUserMedia. Native synchronous APIs and signatures are unchanged.

Live output

Create the browser engine asynchronously, add the same SampleSource values used by a desktop engine, and resume it from a click or key handler:

use kael_audio::{AudioEngine, BrowserAudioEngineConfig, SineSource};

let engine = AudioEngine::new_async_with_config(
    BrowserAudioEngineConfig::new(2, 256, 4)?,
).await?;
let voice = engine.play_source(
    Box::new(SineSource::new(440.0, engine.sample_rate(), 0.2)),
    1.0,
)?;
engine.set_voice_gain(voice, 0.5)?;
engine.resume_async().await?;

The output worklet requests only the space remaining in its fixed chunk window. One request can be outstanding at a time. Rust reuses its mixer scratch buffer, produces at most that requested count, and transfers each Float32Array rather than retaining a second JavaScript copy. Samples are finite-checked and clamped to -1.0..=1.0 before they reach the device. Control is event-driven through MessagePort; there is no timer or animation-frame polling loop.

AudioEngineHandle is main-thread-only and weak in a browser build. Cloned handles can control voices while the owning AudioEngine exists, but cannot keep the graph alive. close_async, owner drop, processor failure, and message delivery failure detach handlers, stop the processor, disconnect the node, discard sources, close the port, and close the AudioContext. A setup future dropped while the worklet module is loading also closes its pending context.

Use poll_event for the bounded Running, OutputUnderrun, ProcessorError, and Closed stream, underrun_frames for its cumulative counter, and take_error for the latest content-safe diagnostic. Event storage holds at most 64 entries and drops the oldest entry when full.

Devices and microphone capture

use kael_audio::{
    AudioInputStream, BrowserAudioCaptureConfig, default_input_device_async,
    input_devices_async, output_devices_async,
};

let outputs = output_devices_async().await?;
let inputs = input_devices_async().await?;

let microphone = default_input_device_async().await?;
let stream = AudioInputStream::from_input_device_async_with_config(
    &microphone,
    BrowserAudioCaptureConfig::new(1, 1_024, 4)?
        .with_signal_processing(true, true, true),
    |samples, format| {
        // Copy/enqueue promptly. Returning this callback releases one credit.
        consume_promptly(samples, format);
    },
).await?;
let _ = (outputs, inputs, stream);

Browsers commonly hide device labels before an origin has media permission. Kael preserves that distinction through label_available and a neutral fallback name. Origin-scoped device identifiers stay private and are redacted from Debug. Enumeration retains at most 1,024 audio descriptors and 1,024 UTF-8 bytes per label or identifier. An empty list is valid. A default lookup returns DeviceUnavailable when no matching kind exists.

Stable non-default output routing is not yet interoperable across Kael's browser targets. AudioEngine::from_output_device_async accepts the default descriptor and returns OutputRoutingUnsupported for another sink; it never silently routes to a different speaker.

Capture converts the selected stream into the requested 1–8 interleaved channels. The worklet owns a fixed number of credits and transfers a chunk only when a credit is available. The main-thread callback returns that credit after it completes. When every credit is in flight, the worklet drops frames and reports the monotonic total through CaptureOverflow rather than extending the port queue. Chunk length, sequence, and drop counters are validated before application code runs. Invalid delivery, processor failure, track end, a panicking callback in an unwind-enabled build, explicit close, or drop stops all tracks and closes the graph.

getUserMedia requires a secure context and normally a user activation and permission. The stable typed outcomes distinguish PermissionDenied, DeviceUnavailable, UserActivationRequired, ApiUnavailable, and graph failures without retaining the browser's potentially sensitive exception text. The Web platform does not provide a portable abort signal for an outstanding permission prompt. Keep the returned future alive until the user decides; Rust future cancellation is not permission-prompt revocation. After a stream is granted, Kael's pending-track guard stops it on any later setup cancellation or failure.

Bounds and latency

BoundaryMinimumDefaultMaximum
Channels1output 2, capture 18
Frames per chunk128output 256, capture 1,0244,096
Pending chunks/credits2432
Live output voices1,024
Browser audio sample rate8 kHzbrowser-selected192 kHz

Chunks must be a multiple of the browser's 128-frame render quantum. At the maximum configuration, one bridge window contains 32 × 4,096 × 8 = 1,048,576 f32 samples (4 MiB), plus one chunk-sized assembly scratch buffer (at most 128 KiB) and browser-owned graph buffers. The default output window contains up to 1,024 frames, about 21.3 ms at 48 kHz, before the browser's base/output/device latency. Default capture produces one 1,024-frame callback, also about 21.3 ms at 48 kHz, with four delivery credits.

This design deliberately avoids SharedArrayBuffer, COOP/COEP headers, and a shared-memory data race. The AudioWorklet stays on the browser audio rendering thread, while Rust Mixer/DSP work and capture callbacks run only when a port event reaches the browser main thread. UI or Wasm work that blocks that thread can cause an underrun or capture overflow. Keep callbacks short and move larger analysis through Kael's bounded Web Worker bridge.

For a game or workstation that requires worklet-owned Wasm DSP, sub-10-ms synthesis, hundreds of continuously expensive sources, HRTF, room acoustics, or multichannel device spatialization, use a specialized product audio worklet. Kael's current portable spatial scene is equal-power stereo panning with inverse-distance attenuation, not an HRTF or room renderer.

Deployment and release evidence

AudioWorklet and getUserMedia require a secure context; localhost is valid for development. Kael currently installs its worklet from a temporary blob: URL and revokes that URL after addModule settles. A strict Content Security Policy must permit this worklet module in the directives enforced by each target browser. If policy rejects it, construction returns WorkletUnavailable rather than falling back to a high-latency polling path.

scripts/verify-browser-audio-smoke.sh builds an optimized Wasm example, serves it locally, and launches a fresh headless Chrome profile. The gate proves real worklet graph construction/resume, frame-clock progress, bounded playback and control, device-enumeration privacy semantics, typed permission-denied capture, and explicit close/weak-handle cleanup. CI denies microphone permission and uses a fake media device, so it intentionally does not claim successful physical microphone capture. Pure injected protocol tests separately cover output request clamping plus capture size/order/drop-counter validation.

Office and PDF document bytes

Kael's document pipeline keeps file acquisition, persistence, container parsing, and application semantics separate. That separation lets a suite use the same Rust code on desktop and the web:

  1. App::prompt_for_files or FileUpload returns bounded ExternalFile bytes.
  2. kael_office::OfficePackage::open parses DOCX, XLSX, or PPTX bytes, while kael_pdf::PdfDocument::from_bytes parses PDF bytes.
  3. The app maps extracted content into its own document, sheet, slide, or canvas model and renders that model with ordinary Kael views.
  4. OfficePackage::to_bytes, PdfDocument::to_bytes, or an app serializer produces output bytes.
  5. App::save_file_bytes performs native Save As or a browser Blob download.

There is no browser-only document model and shared business logic does not need filesystem paths.

DOCX, XLSX, and PPTX foundation

Enable the office feature on kael, or depend directly on kael_office:

#![allow(unused)]
fn main() {
use kael::office::{OfficePackage, OfficeText};

fn import_office(bytes: &[u8]) -> anyhow::Result<()> {
    let package = OfficePackage::open(bytes)?;
    match package.extract_text()? {
        OfficeText::Document(document) => {
            println!("{} paragraphs", document.paragraphs.len());
        }
        OfficeText::Spreadsheet(workbook) => {
            println!("{} worksheets", workbook.sheets.len());
        }
        OfficeText::Presentation(deck) => {
            println!("{} slides", deck.slides.len());
        }
    }
    Ok(())
}
}

The portable OOXML/OPC API detects standard DOCX/XLSX/PPTX main content types, lists and reads parts, parses core properties and relationships, resolves safe internal targets, and safely replaces, adds, or removes raw parts. Export is deterministic: parts are sorted and ZIP timestamps, permissions, compression, and compression level are normalized. Unknown parts, including relationships, are retained byte for byte across a read/export round trip.

Text extraction provides an interchange baseline:

  • DOCX: text runs grouped into paragraphs, including table-cell paragraphs, tabs, and explicit breaks;
  • XLSX: shared strings, inline strings, booleans, numeric values, and cached formula results, grouped by worksheet and cell reference; and
  • PPTX: DrawingML paragraphs grouped by slide in relationship order.

kael_office does not paginate Word files, calculate formulas, lay out slides, render charts or SmartArt, execute macros or embedded objects, or promise pixel parity with Microsoft Office. A suite should layer its semantic model, layout engine, collaboration protocol, and high-fidelity adapters on this bounded foundation. When adding or removing parts, callers must also maintain content types and relationships; replacing an existing part is the safest primitive.

Portable PDF services

Enable pdf on kael, or use kael_pdf directly. PdfDocument::from_bytes and async open_from_memory work on desktop and wasm32-unknown-unknown. Page count and size, metadata, outlines, text, search, links, sidecar annotations, and schematic previews share the same APIs. annotations_to_bytes and load_annotations_from_bytes make annotations persistable through kael_document, IndexedDB, or an app service. to_bytes provides bounded PDF bytes for download.

Browsers do not expose arbitrary filesystem paths. PdfDocument::open, save, and save_annotations return a downcastable PdfPlatformError there; use picker bytes and save_file_bytes. Native path methods retain atomic file and sidecar persistence.

The built-in schematic_preview is an extracted-text and annotation placeholder, not PDF graphics rasterization. It does not draw original fonts, images, vectors, forms, or signatures. Use a dedicated sandboxed renderer for pixel-faithful PDF pages.

Bounds and hostile files

Office input and output are capped at 256 MiB, with at most 65,536 parts, 64 MiB per expanded part, 256 MiB total expanded data, a 500:1 ratio limit after a small-file allowance, safe UTF-8 relative names, no duplicates or encryption, and bounded XML depth, event, text, paragraph, string, and cell work. Traversal, package escapes, DTDs, unsafe relationship targets, and ambiguous names are rejected.

PDF input/output is capped at 256 MiB, with bounded object, page, text, cache, preview, annotation, link, outline, search, and metadata work. See its crate API for exact per-operation values.

These limits constrain Kael-owned work; they are not a process sandbox. Parse large or hostile files in a browser worker or restricted native worker process so malformed input cannot stall the UI or share the main application trust boundary.

Realtime Networking

kael_net::WebSocketClient is the shared live collaboration transport for desktop and browser builds. It uses a private Tungstenite/Rustls worker on native targets and web_sys::WebSocket in WebAssembly, but exposes the same configuration, message, event, backpressure, close, and reconnection types. It does not require a Tokio runtime.

Open a checked connection

Core realtime descriptors bridge directly to the transport. A network policy is mandatory at the side-effect boundary:

#![allow(unused)]
fn main() {
use kael::{
    AppRealtimeConnection, AppRealtimeReconnectPolicy, NetworkPolicyBuilder,
};
use std::time::Duration;

let policy = NetworkPolicyBuilder::new()
    .allow_host("collab.example.com")
    .build_checked()?;

let descriptor = AppRealtimeConnection::websocket(
    "wss://collab.example.com/session",
)
.protocol("kael.collab.v1")
.max_message_bytes(1024 * 1024)
.reconnect_policy(AppRealtimeReconnectPolicy::new(
    5,
    Duration::from_secs(1),
    Duration::from_secs(30),
))
.network_policy(policy)
.build_checked()?;

let socket = descriptor.open_websocket_transport()?;
Ok::<(), Box<dyn std::error::Error>>(())
}

Applications using kael_net without core descriptors can build a WebSocketConfig directly and pass an implementation of WebSocketHostPolicy to WebSocketClient::connect. DenyAllWebSocketHosts and AllowAllWebSocketHosts make the decision explicit; production apps should normally use Kael's checked host allow-list.

Poll socket.poll_event() from the application event loop. Every event has a monotonically increasing sequence number and contains one of Open, Message, Error, Reconnecting, or Closed. Text and binary payloads retain transport order. try_send never blocks the UI thread and returns Backpressure when either the outbound count or byte budget is full.

Bounds

The production defaults are:

BoundDefaultChecked maximum
Queued inbound messages1,02465,536
Queued outbound messages25665,536
One message16 MiB128 MiB
Queued inbound payloads32 MiB512 MiB
Queued outbound payloads16 MiB512 MiB
Browser bufferedAmount threshold4 MiB512 MiB
Native connect/TLS timeout15 seconds5 minutes
Reconnect attemptsdisabled100

Lifecycle events use a small bounded reserve in addition to the configured message count. If an app does not drain inbound messages within its count or byte budget, the transport emits InboundBackpressure and closes rather than growing memory without limit. Native Tungstenite enforces the message limit while framing. The browser only exposes a complete MessageEvent, so one oversized browser message is necessarily materialized by the browser before Kael can reject it and close the socket.

The native timeout covers TCP connection attempts and the WebSocket/TLS handshake. Hostname resolution is performed by the operating system resolver; like std::net::ToSocketAddrs, it does not expose a separately cancellable DNS deadline.

Delivery and reconnection

Automatic reconnect applies only to abnormal loss. An application close and clean peer codes 1000 or 1001 are terminal. Messages still in Kael's bounded outbound queue remain ordered across a reconnect. A message already handed to the operating system or browser is not replayed because the transport cannot know whether the peer processed it; collaboration protocols that need exactly-once effects should use application message IDs and acknowledgements.

Each failed attempt emits sanitized Error, Closed, and Reconnecting events in that order. A successful open resets the attempt counter. Dropping the final client clone removes browser handlers and timers or asks the native worker to terminate within its socket timeout. close is the deterministic choice when application code needs a close event.

Browser security and parity boundaries

  • Browsers own DNS, proxies, certificates, cookies, CSP, mixed-content rules, and the HTTP upgrade. An HTTPS page normally needs a wss:// endpoint.
  • Browser WebSockets cannot set arbitrary handshake headers. The core adapter rejects descriptors containing headers on every target so desktop and web do not silently diverge. Authenticate with an appropriate cookie, a short-lived signed endpoint, or an application message after Open.
  • Browser WebSockets cannot originate protocol ping frames. The core adapter therefore rejects descriptor-level protocol heartbeat intervals on every target; portable collaboration protocols can send a bounded application heartbeat with try_send instead.
  • JavaScript only permits close code 1000 or application codes 3000..=4999. Kael uses 4003, 4009, and 4013 on the browser wire for its own terminal guards and normalizes the public close metadata to 1003, 1009, and 1013 to match native behavior.
  • Errors from browsers intentionally contain little diagnostic detail. Kael exposes stable categories and never places a URL, token, payload, or close reason in Debug/Display output.
  • Server-sent events remain a checked AppRealtimeConnectionKind descriptor, but do not yet have a shared live Kael transport.

The maintained release probe starts a local server and verifies the real Chrome path for policy rejection, pre-open queue backpressure, size limits, subprotocol negotiation, ordered text/binary echoes, explicit cancellation, normalized oversize error/close metadata, abnormal-loss reconnection, and teardown:

bash scripts/verify-browser-websocket-smoke.sh

Actions & Keybindings

Kael separates what happens (an action) from how it's triggered (a keybinding or click). Actions are dispatched up the focused element tree, so a keystroke is routed to the nearest handler in the currently focused context — the same model that powers editor-grade keyboard UX.

Defining actions

The actions! macro generates zero-field action types in a namespace:

#![allow(unused)]
fn main() {
use kael::actions;

actions!(editor, [Save, Undo, Redo, Tab, TabPrev]);
}

Each entry becomes a type (Save, Undo, …) implementing the Action trait, with a stable name like editor::Save used for keymaps and dispatch.

Binding keys

Register bindings once at startup with cx.bind_keys. KeyBinding::new takes the keystroke string, the action, and an optional key context that scopes the binding:

use kael::{App, Application, KeyBinding};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    Application::try_new()?.run(|cx: &mut App| {
        cx.bind_keys([
            KeyBinding::new("cmd-s", Save, None),
            KeyBinding::new("cmd-z", Undo, None),
            KeyBinding::new("cmd-shift-z", Redo, None),
            KeyBinding::new("tab", Tab, Some("Editor")),
            KeyBinding::new("shift-tab", TabPrev, Some("Editor")),
        ]);
        // ... open windows ...
    });
    Ok(())
}

Keystroke syntax uses cmd / ctrl / alt / shift modifiers joined with -, and a space separates multi-key sequences (e.g. "cmd-k cmd-s"). Use cmd on macOS and ctrl on Windows/Linux.

Command Registry

Use CommandRegistry when the same app command should be available from a command palette, menu, toolbar, or agent action list:

#![allow(unused)]
fn main() {
use kael::app_runtime::CommandRegistry;

let mut commands = CommandRegistry::new();
commands.register_action_checked("editor.save", "Save", || {
    // persist the active document
})?;

commands.execute("editor.save")?;
}

Prefer register_checked(...) and register_action_checked(...) for generated app chrome. Checked registration rejects empty, padded, overly long, or non-portable command IDs, rejects empty/padded/control-character/overly long names, and catches duplicate IDs before menus or command palettes become ambiguous. Raw register(...) and register_action(...) remain available when an app intentionally wants replacement semantics.

Handling actions

In render, mark the element that owns a focus context with track_focus, then register handlers with on_action(cx.listener(...)). Handlers take &mut self, a reference to the action, the window, and the context:

#![allow(unused)]
fn main() {
use kael::{div, prelude::*, Context, FocusHandle, Render, Window};

struct Editor { focus_handle: FocusHandle }

impl Editor {
    fn on_save(&mut self, _: &Save, _window: &mut Window, cx: &mut Context<Self>) {
        // ... persist ...
        cx.notify();
    }
}

impl Render for Editor {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        div()
            .track_focus(&self.focus_handle)
            .on_action(cx.listener(Self::on_save))
            .on_action(cx.listener(Self::on_undo))
            .child("editor surface")
    }
}
}

Focus & tab order

Create focus handles from the context and arrange tab order with tab_index / tab_stop. Move focus from the window:

#![allow(unused)]
fn main() {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
    let items = vec![
        cx.focus_handle().tab_index(1).tab_stop(true),
        cx.focus_handle().tab_index(2).tab_stop(true),
        cx.focus_handle().tab_index(3).tab_stop(true),
    ];
    let focus_handle = cx.focus_handle();
    window.focus(&focus_handle);
    Self { focus_handle, items }
}
}

Window focus methods: window.focus(&handle), window.focus_next() (Tab), and window.focus_prev() (Shift-Tab). Query state with handle.is_focused(window) and style focused elements with .focus(|s| s.border_color(...)).

Dispatch resolution

An element's .key_context("Editor") scopes matching bindings to that part of the tree. When a keystroke matches, Kael starts at the focused element and walks through its ancestors until an on_action handler accepts the action. A closer handler can therefore override application-level behavior without coupling the keymap to a concrete view type.

Use global bindings for commands that are valid throughout the application and context bindings for editor modes, dialogs, lists, and other surfaces where the same keystroke has a local meaning. See the Astryx showcase for a complete focus-navigation composition.

Plugins & Extensions

Kael ships an extension system with a contribution-point architecture. Extensions run out-of-process and can target one of two execution models: a sandboxed WASM module, or an external process that speaks the extension RPC protocol. The host loads extensions from a manifest, mediates their capabilities through a permission broker, and dispatches commands and notifications to them.

Defining a manifest

Build a PluginManifest with the builder. The positional arguments are id, name, version, api_version, entry_point, and execution_model; contribution points and capabilities are added fluently.

#![allow(unused)]
fn main() {
use kael::{ContributedCommand, ExecutionModel, PluginManifest};

let manifest = PluginManifest::builder(
    "com.example.mock",   // id
    "Mock Plugin",        // display name
    "1.0.0",              // plugin version
    "1.0.0",              // host API version it targets
    "mock.wasm",          // entry point
    ExecutionModel::Wasm,
)
.command(ContributedCommand {
    id: "mock.hello".to_string(),
    title: "Say Hello".to_string(),
    keybinding: None,
})
.build()?;
}

Manifests can also be loaded from disk with PluginManifest::from_json, PluginManifest::from_toml, or PluginManifest::load(path). Manifest validation rejects empty, padded, control-character, overly long, or non-portable IDs/names/versions, unsafe entry-point text, duplicate command or panel IDs, menu items that reference missing commands, and non-object settings schemas before the extension is loaded.

Loading and activating

ExtensionHostRuntime owns the installed extensions for an app. Load a manifest, then activate it — activate_with_broker runs the extension's capability requests through a PermissionBroker first.

#![allow(unused)]
fn main() {
use kael::{ExtensionHostRuntime, PermissionBroker};

let mut runtime = ExtensionHostRuntime::new(&extensions_dir, "my-app");
runtime.load(manifest)?;

let broker = PermissionBroker::new();
for ext in runtime.all().iter().map(|e| e.manifest.id.clone()).collect::<Vec<_>>() {
    runtime.activate_with_broker(&ext, &broker)?;
}
}

Other runtime operations: load_from_directory (dev mode), install_from_path, uninstall, activate / deactivate, unload, send_command, broadcast_notification, and all (returns &ExtensionInfo with manifest, is_active, process_id, load_path, dev_mode).

Crash and restart policy

Extension hosts should keep crash state outside the extension process. Use CrashPolicy::validate, CrashRecord::new_checked, record_crash_checked, should_restart_checked, and next_restart_delay_checked when wiring restart loops. The checked helpers reject invalid extension IDs, zero-delay restart loops, non-finite backoff values, and backoff factors below 1.0; extremely large restart delays saturate to u64::MAX.

#![allow(unused)]
fn main() {
use kael::{CrashPolicy, CrashRecord};

let policy = CrashPolicy::default();
policy.validate()?;

let mut crashes = CrashRecord::new_checked("com.example.mock")?;
crashes.record_crash_checked(&policy)?;

if crashes.should_restart_checked(&policy)? {
    let delay_ms = crashes.next_restart_delay_checked(&policy)?;
    // Schedule the extension restart after delay_ms.
}
}

Contribution points

Extensions extend the host by contributing entries through the builder:

Builder methodContributes
.command(ContributedCommand)A command (id, title, optional keybinding)
.menu_item(ContributedMenuItem)A menu entry (target_menu, label, command_id)
.panel(ContributedPanel)A panel/view (id, title, default_position)
.settings_schema(json)A JSON schema for configurable settings
.capability(Capability)A requested capability (e.g. Capability::Notification)

Panels position with PanelPosition::{Left, Right, Bottom, Floating}.

Execution models & RPC

ExecutionModel::Wasm runs the entry point as a sandboxed WebAssembly module. ExecutionModel::ExternalProcess launches a separate binary that connects over the platform transport and exchanges messages with the host.

The host and an external extension first complete a handshake (ExtensionHandshake, version-checked against EXTENSION_RPC_VERSION), then exchange a typed envelope:

DirectionTypeKey variants
host → extExtensionRequestActivate, Deactivate, Shutdown, GetContributions, ExecuteCommand { command_id, args }
ext → hostExtensionResponseAck, Contributions(Contributions)
host → extExtensionNotificationSettingsChanged { key, value }

Broadcast a notification to every active extension:

#![allow(unused)]
fn main() {
use kael::ExtensionNotification;

runtime.broadcast_notification(ExtensionNotification::SettingsChanged {
    key: "theme".to_string(),
    value: serde_json::json!("dark"),
});
}

For the complete contract, see the ExtensionRuntime, ExtensionBroker, and ExternalExtensionProcess API documentation. The consolidated Astryx showcase demonstrates the UI primitives used to present extension state, commands, and logs; extension lifecycle and transport behavior are covered by the process integration tests so the example gallery does not duplicate security-sensitive runtime code.

Multi-Process & IPC

Kael supports an native desktop multi-process architecture: the UI runs in the main process while heavy or untrusted work runs in supervised child processes that communicate over typed IPC. Transport is platform-native — Unix domain sockets on macOS/Linux, named pipes on Windows — and the framework handles framing, request/response correlation, progress streaming, and crash reporting for you.

Process model

Every process has a class describing its role:

#![allow(unused)]
fn main() {
use kael::ProcessClass;

ProcessClass::Ui;        // the main UI process
ProcessClass::Worker;    // background compute
ProcessClass::Media;     // media decode/playback
ProcessClass::Extension; // sandboxed plugins (see Plugins & Extensions)
}

A child process is described by a ProcessInfo. For generated host code, prefer ProcessInfoBuilder so empty names, missing executables, invalid environment keys, NUL-containing args, and missing required paths fail before the supervisor starts:

#![allow(unused)]
fn main() {
use kael::{ProcessId, ProcessInfoBuilder};

let info = ProcessInfoBuilder::worker(ProcessId(0), "thumbnailer")
    .executable("/path/to/worker-binary")
    .require_existing_executable()
    .arg("--quiet")
    .env("RUST_LOG", "warn")
    .build_checked()?;
}

Builders exist for each role: ProcessInfoBuilder::worker, ProcessInfoBuilder::media, and ProcessInfoBuilder::extension. The raw ProcessInfo::worker, ProcessInfo::media, and ProcessInfo::extension constructors remain available when an app owns lower-level validation.

Spawning a worker (host side)

WorkerHost owns the socket directory and supervises spawned children. request sends a typed payload and blocks for the response; fire_and_forget sends without waiting; health_check pings the child.

#![allow(unused)]
fn main() {
use kael::{
    ProcessClass, ProcessId, ProcessInfoBuilder, ProcessSpawnOptionsBuilder,
    WorkerHost,
};

let mut host = WorkerHost::with_temp_dir();
let info = ProcessInfoBuilder::worker(ProcessId(0), "thumbnailer")
    .executable(worker_binary_path)
    .require_existing_executable()
    .build_checked()?;
let spawn_options = ProcessSpawnOptionsBuilder::new()
    .restart_on_failure(3, std::time::Duration::from_secs(1))
    .heartbeat_interval(std::time::Duration::from_secs(5))
    .missed_heartbeats_before_unhealthy(3)
    .build_checked()?;

let worker = host.spawn_worker_with_options(ProcessClass::Worker, info, spawn_options)?;

worker.health_check()?; // round-trip ping

let response: serde_json::Value = worker.request(serde_json::json!({
    "op": "echo",
    "message": "hello from host",
}))?;
assert_eq!(response["message"], "hello from host");
}

The worker child

The child binary connects back to the host with WorkerClient::connect_from_env (it reads the KAEL_WORKER_SOCKET / KAEL_WORKER_PIPE environment variable the host sets) and serves requests with run. The handler receives a WorkerRequest and a progress callback for streaming intermediate updates, and returns a WorkerResponse or WorkerError.

use anyhow::Result;
use kael::{WorkerClient, WorkerProgress, WorkerRequest, WorkerResponse};

fn main() -> Result<()> {
    let client = WorkerClient::connect_from_env()?;
    client.run(|request, progress| match request {
        WorkerRequest::Ping => Ok(WorkerResponse::Pong),
        WorkerRequest::Execute { payload } => {
            progress(WorkerProgress::Update(serde_json::json!({ "step": 1 })));
            // ... do work ...
            Ok(WorkerResponse::Result(payload))
        }
    })
}

The message types:

TypeVariants
WorkerRequestPing, Execute { payload: serde_json::Value }
WorkerResponsePong, Result(serde_json::Value)
WorkerProgressUpdate(serde_json::Value)
WorkerErrorExecution(String), Cancelled

Supervision & crash handling

Register an event callback to observe lifecycle events. A child that crashes is reported as a SupervisorEvent::Exited rather than taking down the host:

#![allow(unused)]
fn main() {
use kael::SupervisorEvent;

host.on_event(|event| match event {
    SupervisorEvent::Exited { id, .. } => eprintln!("worker {id:?} exited"),
    _ => {}
});
}

Each WorkerHandle exposes id() to correlate it with supervisor events.

ProcessSpawnOptionsBuilder validates restart and heartbeat policy: restart counts must be non-zero when using restart_on_failure, backoff must be greater than zero, heartbeat intervals must be greater than zero, and the missed heartbeat threshold must be non-zero.

Extension processes

Extension children use the same transport but a richer RPC envelope (handshake, contribution discovery, command dispatch). They are managed by ExtensionHostRuntime rather than WorkerHost — see Plugins & Extensions.

Security & Permissions

Kael provides a capability-based security model for controlling what extensions and child processes can access.

Permission system

#![allow(unused)]
fn main() {
use kael::security::*;

let mut manager = PermissionManager::new();

// Request permission
let request = PermissionRequest::new(
    PermissionKind::FileSystem,
    "Read project files",
);

match manager.check(&request) {
    PermissionStatus::Granted => { /* proceed */ },
    PermissionStatus::Denied => { /* blocked */ },
    PermissionStatus::Prompt => { /* ask user */ },
}
}

Network policy

Control outbound network access:

#![allow(unused)]
fn main() {
let policy = NetworkPolicyBuilder::new()
    .allow_host("api.myapp.com")
    .allow_url("https://cdn.myapp.com/assets/app.js")?
    .build_checked()?;

assert!(policy.check_url("https://api.myapp.com/v1/sync")?);
assert!(!policy.check_url("https://evil.example.com/track")?);
}

Use NetworkPolicy::DenyAll for sandboxed workers by default, AllowList for app-owned services, and DenyList only when most hosts should be allowed. The checked builder rejects malformed hosts, full URLs in host fields, non-HTTP(S) URLs, duplicate host entries, and mixed allow/deny lists.

Process capabilities

Limit what child processes can do:

#![allow(unused)]
fn main() {
let limits = ProcessLimits {
    max_memory_bytes: Some(512 * 1024 * 1024),
    max_cpu_percent: Some(50.0),
    max_open_files: Some(256),
    network_allowed: true,
};

let mut capability = ProcessCapability::new(42, "worker", limits);
assert!(capability.check_network());
}

File access bookmarks

Kael's standard UI threat model grants only PathScope::UserSelected file read/write capabilities by default, so open/save dialogs and browser file pickers work after an explicit user gesture. This does not grant arbitrary path access: PathScope::Any, app-data access, and worker access still require an explicit application policy or delegated bookmark.

Use file access bookmarks after open/save dialogs, recent-project restore, or extension handoff flows. They keep app-owned path access explicit and can issue temporary tokens instead of passing raw paths everywhere.

#![allow(unused)]
fn main() {
let bookmark = FileAccessBookmark::builder("workspace.main", workspace_dir)
    .scope(PathScope::UserSelected)
    .read_write()
    .require_existing_path()
    .canonicalize_path()
    .ttl_seconds(3600)
    .build_checked()?;

let mut tokens = AccessTokenStore::new();
let token = bookmark.issue_token(&mut tokens, now_unix_seconds)?;

for capability in bookmark.capabilities() {
    broker.grant(worker_process, capability);
}
}

Credential storage

Secure credential management via OS keychain:

#![allow(unused)]
fn main() {
let keychain = KeychainStore::new("my-app");
keychain.write("api-token", "secret-value")?;
let token = keychain.read("api-token")?;
keychain.delete("api-token")?;
}

Release dependency audit

Release CI runs cargo audit -D warnings, so a new vulnerability, unsoundness, unmaintained dependency, or yanked crate fails the gate. The checked-in scripts/ci/audit-dependencies.sh contains the complete reviewed exception list. Cargo.lock still records Wry's target-conditional GTK3 metadata because Wry is the supported Windows WebView2 host, including RUSTSEC-2024-0429 for glib 0.18. The audit script separately proves that neither Linux WebView feature spelling can reach Wry, GTK3, WebKitGTK 4.1, or Blade. Linux ships only the GTK4/WebKitGTK 6 host. The other reviewed exceptions are unmaintained transitive parser/build crates, not known exploitable vulnerabilities. Remove an exception as soon as the dependency that leaves the lockfile permits it.

Linux WebView hosting

Kael's maintained Linux WebView stack uses one GTK4-owned window hierarchy on X11, XWayland, and native Wayland. The archived GTK3 stack is not shipped; WebViews never use a detached top-level overlay.

Desktop pathKael featureNative stackContract
Native WaylandwebviewGTK4 + GSK + WebKitGTK 6.0Maintained production path
X11 or XWaylandwebviewGTK4 + GSK + WebKitGTK 6.0Maintained production path
Deprecated feature spellingwebview-legacy-gtk3Redirects to GTK4 + WebKitGTK 6.0Source-compatible alias only
Raw Wayland/X11 without the GTK4 hostwayland / x11Kael-owned native surfaceWebViews unavailable
Headlessanyno native browser surfaceWebViews unavailable

Maintained GTK4 production host

Enable the portable feature when an application needs WebViews on any desktop:

[dependencies]
kael = { version = "0.4", features = ["webview"] }

Ubuntu/Debian builders need libgtk-4-dev and libwebkitgtk-6.0-dev in addition to Kael's standard Linux packages. webview-gtk4 and webview-wayland-gtk4 remain Linux-specific compatibility spellings. The deprecated webview-legacy-gtk3 name also selects this maintained graph; no Kael feature pulls the archived GTK3/WebKitGTK 4.1 stack on Linux.

GTK owns ApplicationWindow -> Fixed, with a retained-scene Picture and each WebKit view as siblings. The Kael scene is converted to cached GSK nodes; it is not screen-scraped or copied into a detached overlay. This gives the window and its WebViews one compositor surface, one scale/monitor lifecycle, one focus and input hierarchy, and one minimize/workspace lifetime.

The host implements:

  • retained Scene primitives, text/image atlases, paths, sprites, patterns, shadows and supported backdrop effects through GSK;
  • declarative WebView bounds, clipping, visibility, opacity and focus;
  • URL/header/HTML navigation, history, reload/stop, zoom, find, print, downloads, cookies, named/incognito profiles, user scripts, IPC and native permission policy;
  • app-owned custom protocols, including main-document navigation and same-origin subresources with status, MIME type, headers, and bounded body;
  • pointer, wheel, keyboard, touch, IME composition, focus traversal, window and WebView file drag/drop, rich bounded clipboard content, and AT-SPI updates;
  • monitor/backing-scale changes, raw Wayland or X11 window/display handles, XDG print parenting, native pointer lock, move/resize/menu/decorations, mouse passthrough, and retained-scene PNG export.

WebViews remain rectangular native islands above the retained scene. Kael hides a view when a non-translation transform would make its native rectangle incorrect. Arbitrary rotation/perspective through WebKit content and drawing retained content over the middle of a WebView are not claimed. That is why the overall host support level remains Partial while many individual operations report Full.

Backend selection

Selection is deterministic:

  1. KAEL_HEADLESS selects headless mode.
  2. KAEL_LINUX_BACKEND=x11|wayland|headless wins when set.
  3. GDK_BACKEND ordering is honored when the named backend has a valid display.
  4. Otherwise Wayland is selected when WAYLAND_DISPLAY is available, followed by X11 when DISPLAY is available.
  5. With neither display available, Kael uses its headless fallback.

If an application selects a raw X11 or Wayland backend without compiling the GTK4 WebView host, the capability report marks every WebView operation unavailable and controller commands return a deterministic error. Kael never creates a visually adjacent top-level window and calls it embedded.

Why the GTK-owned host is necessary

The raw Wayland backend owns a wl_surface through Kael's Wayland client connection. Wayland cannot attach a GTK surface from another client as a positioned child, and Wry's Wayland constructor requires a GTK container. A second GTK top level would break compositor placement, clipping, focus, input methods, accessibility, minimization, and workspace movement.

The native host therefore changes ownership of the complete window rather than patching only WebView rendering. GTK/GDK supplies the Wayland or X11 handles, and Kael's scene, event translation, services, and WebKit widgets all live inside that hierarchy.

Release acceptance

scripts/ci/run-linux-webview-wayland-gtk4.sh starts a real headless Weston session with DISPLAY unset. It runs two bounded proofs:

  1. a focused same-GDK-surface scene/WebKit composition test; and
  2. the production PlatformWindow implementation with retained-scene PNG export, raw Wayland handles, app-protocol navigation and subresources, page-to-host IPC, host-to-page messaging, JavaScript result serialization, current URL state, and clean application shutdown.

scripts/ci/run-linux-webview-xwayland.sh selects the same maintained host through GDK's X11 backend and proves retained GSK output, raw X11 handles, native pointer-lock acquisition and release after a native menu interaction, custom protocols, IPC, JavaScript, URL state, and event-driven idle operation. The scripts fail unless every stage marker is present. Mesa software rendering is used on CI machines without a physical render node, while still exercising the compositor/client EGL path. Logs are retained as release artifacts. The deprecated feature alias is checked separately to prove it cannot reintroduce the archived stack or substitute a different host.

Run both native proofs locally with:

bash scripts/ci/run-linux-webview-wayland-gtk4.sh
bash scripts/ci/run-linux-webview-xwayland.sh

For operation-specific decisions, query WebViewCapabilityReport::current() instead of inferring support from the OS name. It distinguishes full engine operations from native-island limitations and from a feature that was not compiled.

Crash Reporting

Kael's kael_diagnostics crate captures crashes and persists reports so they can be submitted on the next launch. There are two layers:

  • Panic capture — a Rust panic hook (CrashReporter::install_hook) records the panic message, a resolved backtrace, breadcrumbs, and host info.
  • Native capture — OS-level handlers (CrashReporter::install_native) catch crashes that never unwind through Rust: segmentation faults, bus errors, illegal instructions, floating-point exceptions, aborts, and crashes originating in C/FFI/GPU-driver code.

Without native capture, a segfault or abort() produces nothing. With it, the crash is recorded to disk and turned into a submittable report on the next run.

Installing

Install both layers at startup. Native capture is opt-in:

#![allow(unused)]
fn main() {
use kael_diagnostics::{BreadcrumbBuffer, CrashConsent, CrashReporter};

let mut reporter = CrashReporter::new("com.example.app", BreadcrumbBuffer::new(64))?;
reporter.set_release(env!("CARGO_PKG_VERSION"));
reporter.set_environment("production");
reporter.set_endpoint("https://crashes.example.com/submit");
reporter.set_http_client(http_client.clone());

reporter.install_hook();    // Rust panics
reporter.install_native()?; // SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGABRT, FFI, etc.
}

Pre-crash context (app version, environment, OS, architecture, session id, pid) is captured at install_native() time into a pre-opened artifact — never inside the crash handler, which must stay async-signal-safe.

Detecting and submitting prior crashes

Call check_and_submit_pending early in startup. It detects crashes left by the previous run, converts them to JSON reports, and — only with consent — submits all pending reports through the configured HTTP endpoint:

#![allow(unused)]
fn main() {
let summary = reporter.check_and_submit_pending(CrashConsent::granted()).await?;
if summary.detected_any() {
    for message in &summary.messages {
        eprintln!("recovered from prior crash: {message}");
    }
}
}

On orderly shutdown, mark the session clean so the next launch does not treat it as an unclean exit:

#![allow(unused)]
fn main() {
reporter.mark_clean_exit()?;
}

PriorCrashSummary distinguishes:

  • native_crashes — a handler fired and a signal record was decoded.
  • unclean_exits — the previous run left a marker but no native record (for example SIGKILL, an OOM kill, or power loss); reported but with no signal detail.

CrashConsent mirrors the release UpdatePolicy style and defaults to withheld. Reports are always collected and retained on disk, but they are never submitted unless the application explicitly opts in:

#![allow(unused)]
fn main() {
let consent = if user_enabled_crash_reporting {
    CrashConsent::granted()
} else {
    CrashConsent::withheld()
};
reporter.check_and_submit_pending(consent).await?;
}

With consent withheld (or no endpoint/HTTP client configured), prior crashes are converted to JSON reports and kept on disk but not uploaded.

What is captured, per platform

The native handler path is deliberately minimal so it can run inside a signal / exception handler without allocating, locking, or formatting. It writes a small fixed-shape record; everything human-readable is reconstructed on the next launch.

CapabilitymacOSLinuxWindows
Mechanismsigaction (SIGSEGV/SIGBUS/SIGABRT/SIGILL/SIGFPE)sigaction (same set)SetUnhandledExceptionFilter
Signal / exception codeYesYesYes (exception code)
Fault addressYes (si_addr)Yes (si_addr)Yes (ExceptionAddress)
BacktraceFrame-pointer walk (x86_64/aarch64)Frame-pointer walk (x86_64/aarch64)RtlCaptureStackBackTrace
Symbolized framesNo (raw addresses)No (raw addresses)No (raw addresses)
Breadcrumbs at crash timeNoNoNo
Pre-crash contextYes (captured at install)YesYes
Verified by CI testYes (real SIGSEGV + abort)cfg-gated, same code pathImplemented, not exercised here

What is not captured by the native path, and why:

  • Full minidumps. Writing a minidump in-process is not async-signal-safe, and the macOS minidump tooling is still early. Kael captures raw return addresses instead and symbolizes them offline (below).
  • Resolved symbols / breadcrumbs / heap state. Resolving symbols or touching the breadcrumb buffer would allocate or lock inside the handler. Breadcrumbs remain available for the Rust panic path.
  • Backtraces without frame pointers. The unix backtrace is a frame-pointer walk. Builds compiled with frame pointers omitted will capture fewer (or no) frames; see the symbolication notes.

Symbolication

Native frames are raw instruction addresses. To turn them into file/line/function information you need the unstripped binary (or its separate debug symbols) for the exact build that crashed.

Keep per-release symbols by archiving the build artifacts:

  • macOS: keep the .dSYM bundle produced alongside the binary.
  • Linux: keep the unstripped binary, or split debug info into a .debug file with objcopy --only-keep-debug.
  • Windows: keep the .pdb emitted next to the .exe.

Resolve captured addresses against the matching build:

# macOS — addresses are absolute load addresses
atos -o MyApp.app/Contents/MacOS/MyApp -arch arm64 0x1042684a0 0x1042687b0

# Linux
addr2line -e ./my-app -f -C 0x4001 0x4002

For frame-pointer-based backtraces to be useful in release builds, compile with frame pointers preserved:

# .cargo/config.toml
[build]
rustflags = ["-C", "force-frame-pointers=yes"]

If you later adopt full minidumps for richer post-mortem analysis, the captured .dmp files can be inspected with minidump-stackwalk against the archived symbols; the current Kael implementation does not write minidumps.

Developer Tools

Kael ships an in-app element inspector — the desktop equivalent of a browser's DevTools. You hover and click any element to see its id, layout bounds, style summary, position in the element tree, and a live frame-timing strip.

The picking machinery (hit-testing, element-state reflection, the side panel geometry) lives in the core kael crate. The panel UI is provided by kael_ui, so the inspector adopts your theme tokens automatically.

One-call setup

Call kael_ui::devtools::install_inspector once at startup, behind #[cfg(debug_assertions)] so release builds never include it:

use kael::{Application, App, KeyBinding, actions};

#[cfg(debug_assertions)]
actions!(myapp, [ToggleInspector]);

fn main() -> Result<(), Box<dyn std::error::Error>> {
    Application::try_new()?.run(|cx: &mut App| {
        kael_ui::init(cx);

        #[cfg(debug_assertions)]
        {
            kael_ui::devtools::install_inspector(cx);
            cx.bind_keys([KeyBinding::new("cmd-alt-i", ToggleInspector, None)]);
        }

        // ... open your window ...
    });
    Ok(())
}

install_inspector does two things:

  1. Registers the default inspector renderer via App::set_inspector_renderer. Without it, toggling the inspector opens a blank 30rem strip — the renderer is the missing piece that turns that strip into a populated panel.
  2. Registers a DivInspectorState reflector via App::register_inspector_element, so every div reports its bounds, content size, and base style to the panel when picked.

Toggling it

install_inspector only registers the renderer; it does not bind a key — that is your app's choice. Wire an action to Window::toggle_inspector:

#![allow(unused)]
fn main() {
#[cfg(debug_assertions)]
let root = root.on_action(|_: &ToggleInspector, window, cx| {
    window.toggle_inspector(cx);
});
}

The dashboard template binds Cmd-Alt-I to this in debug builds. Toggling on enters picking mode: hover an element to highlight it, click to pin the selection. Toggle again to close the panel.

What the panel shows

SectionContents
PathA breadcrumb of the picked element's GlobalElementId, deepening one indent per level, ending with the file:line:col where the element was constructed.
ElementInstance index, layout origin, size, and children content size — read from DivInspectorState.
StyleAny explicitly set display, size, background, and flex direction from the element's base style. Rows for unset properties are omitted.
FramesRecent frame count, average frame time, derived FPS, and p95 / p99 frame times.

Frame timing

In debug builds (and with the inspector feature) every window records a FrameRecord at the end of each Window::draw. The rolling timeline — a 300-frame ring buffer — is exposed via Window::frame_timeline():

#![allow(unused)]
fn main() {
let timeline = window.frame_timeline();
let avg_us = timeline.average_duration_us();   // Option<f64>
let p95_us = timeline.p95_duration_us();       // Option<u64>
let jank   = timeline.detect_jank(16_667);     // frames over ~60fps budget
}

The recording hook is gated behind cfg(any(feature = "inspector", debug_assertions)) and does nothing in release builds, so there is no runtime cost in production.

Building without a Metal toolchain

The inspector lives behind debug_assertions, so a plain debug build includes it. If you build on macOS without Xcode's metal compiler, add the runtime_shaders feature so shaders compile at launch instead of at build time:

cargo run -p your-app --features kael/runtime_shaders

Testing

Browser engine matrix

The release-level browser proof runs the same packaged WebAssembly artifacts in Chromium, Firefox, and WebKit. Install the pinned Playwright dependency and browser binaries, then run:

cargo install wasm-bindgen-cli --version 0.2.122 --locked
python3 -m venv target/browser-matrix-venv
target/browser-matrix-venv/bin/python -m pip install \
  -r scripts/browser-matrix-requirements.txt
target/browser-matrix-venv/bin/python -m playwright install \
  chromium firefox webkit
KAEL_PLAYWRIGHT_PYTHON=target/browser-matrix-venv/bin/python \
  bash scripts/verify-browser-matrix.sh

Use KAEL_BROWSER_MATRIX_ENGINES=firefox for one-engine diagnosis. KAEL_BROWSER_MATRIX_SKIP_BUILD=1 reuses existing packaged artifacts, while KAEL_BROWSER_MATRIX_SKIP_SUITE=1 and KAEL_BROWSER_MATRIX_SKIP_REALTIME=1 are diagnostic-only reductions. KAEL_BROWSER_MATRIX_SKIP_CAPTURE=1 omits only the injected canvas-backed display-capture lifecycle fixture. Release CI uses none of these reductions. The report records pointer activation, the retained frames scheduled by the component ripple, IME state, virtual-scroll latency, and renderer identity. Hardware runs require multiple post-click frames. Software-fallback runs require one post-click frame because a loaded SwiftShader frame can outlast the ripple; the report stores both the observed and required frame counts. Evidence is written to target/browser-matrix.

Generated project parity

The maintained release gate invokes the actual CLI, leaves its generated src/main.rs and Cargo.toml byte-for-byte unchanged, and checks the same source against both target dependency sets:

KAEL_PLAYWRIGHT_PYTHON=target/browser-matrix-venv/bin/python \
  bash scripts/verify-generated-project-parity.sh

For a fast local compiler and packager preflight without launching Chromium:

KAEL_GENERATED_PARITY_SKIP_BROWSER=1 \
  bash scripts/verify-generated-project-parity.sh

Release CI never uses that reduction.

The verifier creates a temporary nested workspace under target, applies kael and kael_ui through a parent workspace [patch.crates-io] table, seeds resolution from the repository Cargo.lock, and deletes only that temporary workspace on exit. This lets release CI prove the next unpublished Kael version without rewriting the generated project or selecting an unrelated fresh set of transitive versions. It checks the native target, fetches the locked wasm graph, then packages the unchanged binary offline through kael web build. It tests both the default host page and a source-owned HTML and asset shell. The gate requires the pinned wasm-bindgen and Binaryen optimization pass before it applies the release artifact budget and launches the custom static output in pinned Chromium. The browser proof requires successful Wasm initialization, at least two retained frames, non-blank composited pixels, a viewport-filling canvas, and no page or request errors. The untouched generated source and manifest, metadata, hashes, logs, packaged web files, a screenshot, and the JSON report are retained in target/generated-project-parity-evidence.

The publish workflow attests that the exact candidate SHA already has a successful Platform Readiness run, so this proof remains release-blocking without running the whole matrix twice. The native renderer jobs independently scaffold the same unchanged template on Windows and Linux to prove that the generated desktop binary launches there too.

Native renderer runtime smoke

Platform readiness does not treat a successful native compile as renderer evidence. native_renderer_smoke rejects KAEL_HEADLESS, opens and activates a real window, advances four visually different retained scene revisions, reads the selected GPU identity, and exports the final scene through the backend at device-pixel resolution. The gate decodes the PNG and checks its dimensions, visible-pixel ratio, color diversity, and luminance range before exiting itself within a 20-second deadline.

Linux runs the X11 surface path under a private Xvfb server and selects Mesa lavapipe explicitly on the GPU-less hosted runner. This proves Blade/Vulkan window, presentation, and readback behavior with an honestly reported software adapter; it is not a hardware-throughput or native-Wayland-compositor claim. Windows runs the same scene through Direct3D 11. Normal applications prefer a compatible hardware adapter and fall back to WARP if enumeration finds none. Hosted CI explicitly sets the strictly parsed KAEL_FORCE_WARP=1 proof switch, then requires the adapter to identify itself as software. This is likewise a correctness/liveness gate rather than Direct3D hardware performance evidence.

# Linux, with Xvfb and Mesa Vulkan packages installed
KAEL_NATIVE_RENDERER_USE_SOFTWARE=1 \
  bash scripts/ci/verify-linux-native-renderer.sh

# Windows
pwsh -File scripts/ci/verify-windows-native-renderer.ps1

Both scripts then invoke kael new, build the untouched generated source, and prove that its visible native window is mapped. Windows closes the starter via its normal Win32 window lifecycle. The Linux starter is deliberately an interactive app with no test-only exit branch, so CI captures its X11 window geometry and pixels and then stops it externally under a bound. Evidence lives under target/native-renderer-smoke/{linux,windows} and includes adapter logs, the decoded-scene PNG, generated-source snapshots, and native-window evidence.

Native WebView runtime smoke

Platform readiness executes webview_smoke against WKWebView on macOS, WebView2 on Windows, and the maintained GTK4 + WebKitGTK 6 host on both Weston/XWayland and native Wayland. The macOS verifier explicitly removes KAEL_HEADLESS from the child environment and requires page-load, page-to-host IPC, host-to-page messaging, JavaScript result, and current-URL stages plus successful focus and zoom commands before accepting the final marker:

bash scripts/ci/verify-macos-wkwebview.sh

The log is retained in target/macos-wkwebview-smoke/wkwebview.log. This is a real platform runtime gate; a headless capability check cannot satisfy it.

The native-Wayland gate runs scripts/ci/run-linux-webview-wayland-gtk4.sh. It requires the focused same-surface composition proof and the production PlatformWindow proof, including retained-scene PNG export, raw Wayland handles, app-owned custom protocol navigation and subresources, page/host IPC, JavaScript results, URL state, and clean shutdown under a headless Weston compositor.

scripts/ci/run-linux-webview-xwayland.sh selects the same production host through GDK's X11 backend and requires X11 raw handles, GSK scene export, the native XI2 pointer-lock implementation to acquire and release after a native context menu has been shown, the full WebView protocol/IPC stages, event-driven idle behavior, and clean shutdown without terminating XWayland. The legacy feature spelling is compiled in isolation to prove it redirects to the maintained host and cannot reintroduce GTK3.

Kael ships a headless test platform so you can drive real windows, views, and input from ordinary unit tests — no display server, GPU, or windowing backend required. Tests run the same on macOS, Windows, and Linux CI.

#[kael::test]

Annotate a test with #[kael::test] and declare a &mut TestAppContext parameter. The macro builds the headless app context, seeds the RNG, and tears everything down afterward:

#![allow(unused)]
fn main() {
use kael::{TestAppContext, WindowOptionsBuilder};

#[kael::test]
fn opens_a_window(cx: &mut TestAppContext) {
    let window = cx.update(|cx| {
        cx.open_window(WindowOptionsBuilder::new().title("Test"), |_, cx| {
            cx.new(|_| MyView::default())
        })
            .unwrap()
    });
    window.update(cx, |view, _window, _cx| {
        assert!(view.is_ready());
    }).unwrap();
}
}

Parameter types the macro recognizes:

ParameterWhat you get
&mut TestAppContextA headless app context (open one per &mut TestAppContext parameter).
BackgroundExecutorThe test's deterministic background executor.
StdRngA seeded RNG (seed 0, or SEED env var, or #[kael::test(seed = N)]).

Async tests are supported — declare the fn async and the macro drives it on the test executor.

TestAppContext and VisualTestContext

TestAppContext is the entry point. For tests that need a rendered window, use add_window_view, which opens a maximized headless window, renders your root view, and hands back the view plus a VisualTestContext scoped to that window:

#![allow(unused)]
fn main() {
let (view, vcx) = cx.add_window_view(|_window, _cx| MyView::default());
}

VisualTestContext is where window-level testing happens:

MethodUse
simulate_click(point, modifiers)Synthesize a left mouse down + up.
simulate_mouse_move / simulate_mouse_down / simulate_mouse_upLower-level pointer events.
simulate_keystrokes("cmd-s a b")Type a space-separated keystroke sequence.
dispatch_action(MyAction)Dispatch an action into the focused tree.
run_until_parked()Flush the executor so effects, notifies, and redraws settle.
draw(origin, space, fn)Lay out and paint an element directly.

The golden rule: after mutating view state or simulating input, call run_until_parked() before asserting, so pending effects and re-renders complete.

Testing kael_ui components

kael_ui is a normal crate, so the same pattern works for its components. The trick is enabling the headless platform: add kael as a dev-dependency with the test-support feature so TestAppContext is in scope during tests.

# crates/kael_ui/Cargo.toml
[dev-dependencies]
kael = { path = "../kael", version = "0.4.1", features = ["test-support"] }

test-support is platform-agnostic: the test platform mocks the windowing layer, so it does not pull the Linux Wayland/X11 crates into a macOS or Windows test build. (Linux CI that needs real-window rendering under tests can opt in with the test-support-linux-windowing feature.)

Then write a root harness view that renders the component under test and assert on observable behavior. A button-click test, end to end:

#![allow(unused)]
fn main() {
use std::cell::Cell;
use std::rc::Rc;
use kael::{div, point, px, Context, IntoElement, ParentElement as _, Render, Styled as _,
    InteractiveElement as _, TestAppContext, Window};
use kael_ui::prelude::*;

struct Harness { clicks: Rc<Cell<usize>> }

impl Render for Harness {
    fn render(&mut self, _w: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
        let clicks = self.clicks.clone();
        div().size_full().child(
            div().absolute().top(px(0.0)).left(px(0.0)).child(
                Button::new("btn", "Click me")
                    .size(ButtonSize::Lg)
                    .on_click(move |_e, _w, _cx| clicks.set(clicks.get() + 1)),
            ),
        )
    }
}

#[kael::test]
fn button_click_fires_handler(cx: &mut TestAppContext) {
    cx.update(|cx| kael_ui::theme::install_theme(cx, kael_ui::theme::Theme::dark()));

    let clicks = Rc::new(Cell::new(0));
    let (_view, vcx) = cx.add_window_view(|_w, _cx| Harness { clicks: clicks.clone() });

    vcx.simulate_click(point(px(40.0), px(20.0)), Default::default());
    vcx.run_until_parked();

    assert_eq!(clicks.get(), 1);
}
}

Notes:

  • Install a theme first — most components read Theme::dark() tokens during render and kael_ui::theme::install_theme makes them available.
  • Position the component deterministically (here, top-left) so the simulated click lands on it.
  • State that lives on the view (toggles, counters, transition state) is asserted by calling view.update(vcx, |view, _| { ... }) after run_until_parked().

See crates/kael_ui/tests/component_tests.rs for the full set of patterns, including an implicit-transition test on a real div.

CI recipe

Run the suites with the runtime_shaders feature so macOS CI does not need Xcode's metal compiler, and the test-support platform stays headless:

# .github/workflows/ci.yml (excerpt)
jobs:
  test:
    strategy:
      matrix:
        os: [macos-latest, windows-latest, ubuntu-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: 1.97.1
      - name: Test kael
        run: cargo test -p kael --lib --features test-support,runtime_shaders
      - name: Test kael_ui
        run: cargo test -p kael_ui --features kael/runtime_shaders
      - name: Test xtask
        run: cargo test -p xtask

Locally, the same commands work:

cargo test -p kael --lib --features test-support,runtime_shaders
cargo test -p kael_ui --features kael/runtime_shaders
cargo test -p xtask

Release Process

Kael releases are made from one reviewed commit on main. Crate publication, native installers, updater metadata, and the Git tag must all identify that same commit and version. Never publish from a dirty worktree.

Documentation-only changes

Documentation is not a framework release. Changes limited to docs/**, Markdown files such as README.md or crate READMEs, llms.txt, or the docs workflow do not require a workspace version bump, changelog release section, platform rebuild, crate publication, or Git tag.

The Documentation workflow validates those changes on pull requests. A push to main builds and deploys the guide to GitHub Pages. Platform Readiness runs one small classifier for required-check compatibility, then skips the macOS, Linux, Windows, and browser build jobs. The stable Platform readiness check passes after classification, so documentation pull requests remain mergeable.

Keep unreleased documentation improvements on main. They become part of the next crate release naturally when a later code change requires a new version. Do not dispatch Publish crates for a documentation-only commit.

Changes to source, manifests, lockfiles, build scripts, fixtures, or platform workflows are not documentation-only and still require the normal code gates.

Prepare the release candidate

Update the workspace version, kael.dist.toml, scaffold dependency version, and the dated changelog section together. Then run the local gates:

cargo fmt --all --check
bash scripts/ci/verify-cross-targets.sh
bash scripts/ci/audit-dependencies.sh
bash scripts/ci/verify-kael.sh default
bash scripts/publish-all.sh --preflight
bash scripts/ci/verify-docs.sh

On macOS, these commands require full Xcode rather than Command Line Tools for the package archive's default Metal shader build. Select it either system-wide with xcode-select or per shell with DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer.

The documentation gate builds every page, copies llms.txt, rejects orphaned pages, broken local links, duplicate HTML IDs, stale LLM routes, oversized font assets, and guides that stop including the compiled quick-start source. The quick-start example is compiled on native and Wasm targets in Platform Readiness.

The publication preflight selects all 34 crates in dependency order, checks their license and package contents, builds the actual .crate archives as one unpublished workspace set, compiles every extracted archive, and enforces the crates.io 10 MiB compressed archive limit. It does not upload anything.

verify-cross-targets.sh uses Zig to link the portable Linux test graphs from the development machine and separately checks the public Wasm graphs. It is a fast compiler/linker preflight, not runtime evidence: it cannot execute Metal, Direct3D, WebView2, WKWebView, WebKitGTK, or browser engines.

Commit and push the complete candidate before treating runtime evidence as release evidence. That exact push starts the compact Platform Readiness gate: Linux quality/package checks, Linux renderer/WebView runtime, the browser matrix, macOS native/WKWebView/Metal/browser-hardware checks, and two Windows native/WebView/MSI compatibility runners. The Metal browser job must report a non-software WebGL adapter; merely omitting the forced SwiftShader query is not sufficient.

After Platform Readiness is green, run Publish crates with publish=false. This is a cheap attestation: it requires a successful Platform Readiness run whose head_sha exactly matches the checked-out candidate and does not rerun the same platform matrix.

gh workflow run release.yml --ref main -f publish=false
gh run list --workflow release.yml --branch main --limit 1
gh run watch <run-id> --exit-status

Do not substitute a Zig cross-compile for the hosted platform runtime jobs.

Publish crates and tag the commit

The crates-io GitHub environment must contain CARGO_REGISTRY_TOKEN. After the non-publishing workflow run is green, dispatch the same workflow with publish=true:

gh workflow run release.yml --ref main -f publish=true
gh run list --workflow release.yml --branch main --limit 1
gh run watch <run-id> --exit-status

The workflow only publishes from refs/heads/main, requires the exact confirmation generated from the workspace version, uploads crates in dependency order, waits for each registry version to become visible, and can resume a partial upload without overwriting an immutable crates.io version.

After all crates are visible, create the annotated version tag on the exact published commit and push only that tag:

git tag -a v0.4.1 <published-commit-sha> -m "Kael 0.4.1"
git push origin v0.4.1

macOS distribution order

kael.dist.toml may contain the public Developer ID identity and team ID. Keep notary credentials out of the repository. Store them once with Apple's tool and provide only the profile name through KAEL_NOTARY_PROFILE:

xcrun notarytool store-credentials <profile> \
  --apple-id <apple-id> --team-id <team-id>

The production order is deliberate: sign the hardened-runtime app, create the DMG, timestamp-sign the DMG, notarize it, and staple the accepted ticket.

cargo build --release -p kael-cli --bin kael
cargo run -p xtask -- bundle kael.dist.toml \
  --output dist --binary target/release/kael
cargo run -p xtask -- sign kael.dist.toml --artifact dist/kael.dmg
KAEL_NOTARY_PROFILE=<profile> cargo run -p xtask -- \
  notarize kael.dist.toml --artifact dist/kael.dmg

codesign --verify --deep --strict --verbose=4 dist/Kael.app
codesign --verify --strict --verbose=4 dist/kael.dmg
xcrun stapler validate dist/kael.dmg

Bundling signs the app before it enters the disk image. The standalone signer uses a trusted timestamp and omits app-only hardened-runtime flags for a DMG. Production notarization fails closed when KAEL_NOTARY_PROFILE is missing.

Windows and Linux installers

On Windows, install the pinned WiX v4 toolchain used by CI. Supply the PFX path and password through KAEL_WINDOWS_CERTIFICATE and KAEL_WINDOWS_CERTIFICATE_PASSWORD; do not commit the password. Bundling signs the MSI with SHA-256 and a trusted timestamp. Run scripts/ci/verify-windows-msi.ps1 against the result to prove the MSI database, payload hash, extracted executable, and Authenticode status.

Linux bundling produces the .deb and AppImage payloads. The standalone Linux signer creates an armored detached GPG signature using the maintainer's selected key. Verify installer behavior on the same supported distribution baseline used by platform-readiness CI.

Signed updater metadata

Generate the Ed25519 updater key pair once:

cargo run -p xtask -- generate-update-key

Store the private value as KAEL_UPDATE_SIGNING_KEY; never commit or print it in CI logs. Put only the matching public key in updater.public_key in kael.dist.toml. A production xtask publish requires real regular-file artifacts, non-placeholder hashes and sizes, a selected update artifact that is also uploaded, and a private key that matches the configured public key. Dry-run feeds are deliberately unsigned and are not release artifacts.

Browser artifacts

The browser target is published through the same crates and source tree, not as a forked UI implementation. Before tagging, retain the generated-project parity report, optimized Wasm size report, suite-scale report, and Chromium/Firefox/ WebKit matrix report from the exact SHA. Also retain the macOS hardware report, its renderer/vendor identity, the compositor screenshots, and the raw retained framebuffer PNGs. The latter keep visual evidence deterministic when an automation compositor omits a restored WebGL plane. Browser security boundaries such as permission prompts and cross-origin iframe restrictions remain capability differences; they must be handled through Kael's typed capability reports rather than platform-specific view code.

Showcase and starter applications

Kael keeps one maintained example instead of a large set of overlapping demo binaries. The Astryx showcase is an interactive catalog of the framework's UI surface, organized into focused sections for actions, inputs, selection, data display, charts, feedback, navigation, overlays, typography, media, and layout.

git clone https://github.com/Augani/kael.git
cd kael
cargo run -p kael_ui --example astryx_showcase \
  --features "media kael/runtime_shaders"

The sidebar switches sections without launching another process. Controls are live: you can type, drag, sort, resize, open overlays, navigate with the keyboard, and inspect accessible states in the same application.

For deterministic visual or accessibility inspection, the showcase supports section filters such as ASTRYX_SHOWCASE_CHART_SECTION, ASTRYX_SHOWCASE_OVERLAY_SECTION, and ASTRYX_SHOWCASE_LAYOUT_SECTION.

Starter applications

The workspace also contains three application templates. They are maintained as real packages because they demonstrate application architecture rather than isolated component examples:

cargo run -p dashboard-app
cargo run -p messaging-app
cargo run -p workspace-app
  • dashboard-app combines application navigation, cards, charts, and tables.
  • messaging-app combines a conversation list, message history, and composer.
  • workspace-app combines a file tree, editor surface, panels, and status bar.

Create a standalone project from any template with the Kael CLI:

cargo run -p kael-cli -- new my-app

Performance harness

The production performance workload is a Cargo benchmark, not an example:

cargo bench -p kael --bench framework

Use scripts/bench/generate-baseline.sh and scripts/bench/run-comparison.sh to create and compare the checked workload.

Core platform workflows—windows, menus, capture, WebView, printing, plugins, background processes, and release integration—live in the focused guide chapters and automated tests. Keeping security- or platform-sensitive code there prevents copy-pasted example implementations from drifting away from the supported API.

API Documentation

Kael has two complementary documentation surfaces:

  • This guide explains architecture, workflows, platform choices, and complete application concerns.
  • docs.rs renders the public Rust API for every Kael crate release, including searchable modules, types, traits, methods, and source links.

Start with the guide when deciding how a feature should fit into an application. Use rustdoc while implementing it.

Primary API references

NeedAPI reference
Runtime, entities, elements, windows, input, text, layout, renderingkael
Ready-made controls and product UIkael_ui
Procedural macroskael_macros
Storage and migrationskael_storage
HTTP and connected application patternskael_http_client and kael_net
Diagnostics and crash datakael_diagnostics
Documents, Office/PDF bytes, and sharingkael_document, kael_office, kael_pdf, and kael_share
Audio and mediakael_audio, kael_media, and kael_media_engines
Application engineskael_engines

Core module map

The kael crate re-exports its most common types at the crate root. These modules provide focused entry points for larger systems:

ModulePurpose
preludeTraits and types most views import
animation, interpolateTimelines, easing, keyframes, and value interpolation
app_runtime, runtime, worker_apiApplication lifecycle and background execution
virtual_dataVirtualized lists, tables, and tree data
text_engineEditing, selection, composition, and document text behavior
platform_capsRuntime capability truth for platform-dependent workflows
securityPermissions, policies, validation, and safe handoffs
process_model, ipc_transport, supervisorMulti-process applications and worker supervision
plugin, extension_host, extension_rpcIn-process and external extension systems
headless_render, golden, benchmarkRendering tests and performance evidence
scene_graph, graphics_capabilities, gpuCreative surfaces and GPU control
dev_toolsInspector, metrics, and development-time tooling

The Core Concepts, Platform APIs, and Testing chapters explain how these pieces cooperate.

Feature flags

The core crate keeps costly or specialized integrations optional:

FeatureAdds
auto-updateSigned update feeds, checked download queues, and platform installers
lottieNative Lottie and dotLottie decoding and playback
webviewExplicit hosted web surfaces
mediaNative media playback integration
storageStorage primitives through kael_storage
iconsCompact embedded icon catalog with application-asset overrides
diagnosticsMetrics, breadcrumbs, and crash-report integration
documentDocument lifecycle helpers
audioAudio integration
pdfPDF services
officePortable DOCX/XLSX/PPTX OPC parsing, extraction, and deterministic export
notifications-fullNotification services
sharePlatform sharing workflows
screen-captureScreen-capture backend support
agent-toolsStructured capability-planning metadata
runtime_shadersRuntime shader compilation for development

kael_ui separately gates Markdown, native HTML rendering, audio, media, and additional editor grammars. Feature-gated APIs appear in the relevant crate documentation when that documentation profile enables the feature.

Build API docs locally

Build the two primary references without documenting dependencies:

RUSTDOCFLAGS="-D warnings" \
  cargo doc -p kael -p kael_ui --all-features --no-deps

Open target/doc/kael/index.html or run cargo doc -p kael --open for a faster default-feature build. The production-readiness workflow also documents every public library crate with all features enabled, so broken intra-doc links and other rustdoc warnings anywhere in that crate set block a release candidate.

Documentation guarantees

  • kael and kael_ui provide crate-level landing pages with dependency and usage guidance.
  • docs.rs profiles are explicit so documentation builds do not depend on an accidental feature set or unsupported cross-compilation target.
  • Public API links are checked with rustdoc warnings treated as errors.
  • The mdBook guide is built independently, so conceptual documentation cannot hide API-documentation failures.
  • Platform-specific support is described through CapabilityReport; the existence of a type alone is never presented as proof that every backend implements it.

For LLMs

Kael publishes an llms.txt file at the site root. It gives an assistant the current architecture, platform limits, browser build contract, and the right source files to inspect.

Use it as context when an assistant writes or reviews a Kael application:

https://augani.github.io/kael/llms.txt

Every guide page also has a Copy page action in the top bar. It copies clean Markdown for a focused question. Use llms.txt for framework wide context and Copy page for the guide you are working from.

Useful entry points: