AkurAI Build
Menu

Public repository / master

AkurAI-Framework / site/content/docs/rust-ui.md

Latest change c51ade7a9466ad00c3c364890a6ae2a5dae7e3fb - akurai-ui: microphone recording and audio playback through the host (MediaRecorder bridge, native fake, testing helpers) by Ólafur Búi Ólafsson

# Rust UI: `akurai-ui`

`akurai-ui` is the framework's pure-Rust front end: React-style components,
fine-grained signals, and an accessible component kit in the spirit of Radix UI
and shadcn/ui, compiled to WebAssembly. Like the rest of the stack it links
**no external crates** — no `wasm-bindgen`, no `web-sys`. The browser side is
`host.js`, a ~150-line shim that owns a table of DOM nodes and forwards events,
fetches and timers back into Rust.

Use it when a page is an *application* (a planner, a dashboard, an editor) and
you would otherwise reach for React. For mostly-static pages, the
[reactivity](/docs/reactivity) directives and [islands](/docs/islands) remain
lighter.

## Components are functions

A component is a function returning something `IntoView`: an element, text, a
tuple or `Vec` of views, or a closure. Elements are built with a chainable API
that reads top-down like JSX:

```rust
use akurai_ui::prelude::*;
use akurai_ui::kit::{self, Size, Variant};

fn counter() -> impl IntoView {
    let count = signal(0);
    div().class("flex items-center gap-2").child((
        kit::button(Variant::Primary, Size::Md)
            .on_click(move |_| count.update(|c| *c += 1))
            .child("+1"),
        span().child(move || count.get().to_string()),
    ))
}

akurai_ui::app!(counter);
```

## Signals, effects, scopes

`signal(v)` is a reactive cell (`get`, `set`, `update`, `with`). A closure
used as a child or attribute value is a *dynamic region*: it re-runs when a
signal it read changes and patches only its own DOM. `memo` derives values that
notify only on change; `batch` coalesces writes. Every signal, effect and event
listener belongs to a scope, so content that leaves the screen (a closed
dialog, a removed list row) releases everything it created.

- `show(when, then, otherwise)` / `when(cond, then)` — conditional regions.
- `each(items, key, row)` — keyed lists; rows keep their DOM and state across
  updates and receive their item as a `Signal`.
- `portal(view)` — mount at the end of `<body>` (dialogs, toasts).
- `provide_context` / `use_context` — dependency injection down the tree.
  Event handlers and fetch callbacks run in the scope that registered them,
  so they see the same context.

## The kit

`akurai_ui::kit` ships `button`, `icon_button`, `card*`, `input`, `textarea`,
`label`, `field` (label + hint wired with ARIA), `select`, `checkbox` and
`switch` (Radix semantics: `role`, `aria-checked`, `data-state`), `tabs`
(roving focus, arrow keys, Home/End), `dialog` and `confirm_dialog` (portalled,
labelled, Escape/overlay close, focus moved in, trapped and restored),
toasts (`provide_toaster`, `toast`, `toast_error`, an `aria-live` region),
`badge`, `alert`, `avatar`, `separator`, `skeleton`, `spinner`, `empty_state`
and Lucide-style stroke `icons`. Corners follow the house rule: nothing rounder
than 6px.

## Styling

Components use utility classes from [`akurai-css`](/docs/styling) and the theme
tokens (`bg`, `panel`, `fg`, `muted`, `accent`, …). Build the stylesheet with
`akurai_css::build_any`, scanning your `.rs` sources **plus**
`akurai_ui::STYLE_SOURCES` (the kit's own classes), and prepend
`akurai_css::theme::themes_css()` and `akurai_ui::BASE_CSS`. `akurai serve`
also scans `.rs` files when it generates `/utilities.css`.

## Live data: `http::event_source`

A page that mirrors a server stream subscribes to its Server-Sent Events:

```rust
use akurai_ui::http::{event_source, StreamEvent};

event_source("/api/home/events", &["snapshot", "changed"], move |e| match e {
    StreamEvent::Message { name, data } => apply(&name, &data),
    StreamEvent::Error(reason) => connected.set(false), // the browser reconnects
});
```

The stream runs in the scope that opened it and closes when that scope ends
(a page that leaves the screen stops listening). In tests, `open_streams()`,
`push_event(url, name, data)` and `stream_error(url, reason)` drive it.

## Microphone and audio: `platform::record_*`, `play_audio`

```rust
use akurai_ui::platform::{record_start, record_stop, play_audio};

record_start();                       // the browser asks for the microphone once
record_stop(|clip| match clip {       // later: the whole clip, base64, with its mime
    Ok(rec) => send(&rec.mime, &rec.base64),
    Err(reason) => show(reason),      // e.g. NotAllowedError
});
play_audio("data:audio/wav;base64,…");
```

In tests: `recording()`, `finish_recording(mime, base64)`, `fail_recording(reason)`,
`played_audio()`.

## Testing without a browser

Off-wasm, the same host API is served by an in-memory DOM, so components and
whole apps are tested with `cargo test`:

```rust
use akurai_ui::testing as t;

t::render(app);
t::respond("/api/me", 200, r#"{"name":"Ada"}"#);   // answer the app's fetch
t::click(t::get("button:text(Sign in)"));
t::input(t::get("[role=dialog] input"), "Swim practice");
assert!(t::body_html().contains("Swim practice"));
```

Selectors support `tag`, `#id`, `.class`, `[attr]`, `[attr=value]`,
`:text(Exact text)` and descendant combinators. Timers run with
`run_timers()`, and `set_hash` drives the router.

## Shipping

Build the app crate (`crate-type = ["cdylib", "rlib"]`) for
`wasm32-unknown-unknown`, serve the `.wasm` next to `akurai_ui::HOST_JS`, and
boot it:

```html
<script type="module">
  import { boot } from "/akurai-ui.js";
  boot("/app.wasm");
</script>
```

The family planner (`~/Projects/family`) is the reference app.