Files
hemx/examples/kanban.md
T
slhx agent bf7560b7a8 docs(runtime): align docs with hashed runtime asset
Replace fixed /hemx.js wording with helper-provided runtime asset language so docs match hemx-axum runtime_js_path deployment boundaries.

req: axum_integration/005

req: examples/001
2026-06-11 22:38:37 +02:00

9.7 KiB

Milestone: Local-first Multiplayer Kanban

A board with drag-and-drop cards, 60fps pointer-follow, optimistic updates, offline queue, conflict reconciliation, live presence, and SSR-first rendering — all without React/Vue/VDOM, in a single typed Rust codebase.

This is an explicitly advanced/low-level north-star boundary sketch for hemx + hemplate + hemx-sync, not the beginner-facing authoring path. Raw sync/effect/wire vocabulary below is excluded from beginner-facing examples by design.


1. Template: board.heml

<section data-hemx-root="board" data-hemx-slot="board" data-hemx-atom="board">
  <header>
    <h1>{+ self.title +}</h1>

    <form data-hemx-handle="create_card" data-hemx-form="create_card">
      <input name="title" type="text" required>
      <select name="column">
        <template h-for="column in &self.columns" h-key="column.id">
          <option +value="column.id">{+ column.title +}</option>
        </template>
      </select>
      <button>Add card</button>
    </form>
  </header>

  <div class="columns" data-hemx-slot="columns">
    <template h-for="column in &self.columns" h-key="column.id">
      <section class="column" data-hemx-slot="column" +data-column-id="column.id">
        <h2>{+ column.title +}</h2>

        <div class="cards" +data-column-id="column.id">
          <template h-for="card in &column.cards" h-key="card.id">
            <article class="card" data-hemx-slot="card" data-hemx-handle="drag_card" +data-card-id="card.id" draggable="true">
              <strong>{+ card.title +}</strong>
              <small>{+ card.assignee +}</small>
            </article>
          </template>
        </div>
      </section>
    </template>
  </div>

  <aside data-hemx-slot="presence">
    <template h-for="user in &self.online_users" h-key="user.id">
      <span>{+ user.name +}</span>
    </template>
  </aside>
</section>

Notes on keyed scopes:

  • h-for="column in &self.columns" h-key="column.id"required for hemx-addressable nodes inside
  • h-for="card in &column.cards" h-key="card.id"required
  • A slot inside a keyed loop is addressed as a generated keyed resource, never by selector strings or positional DOM targeting
  • Without h-key, hemx rejects the build — no runtime selector fallback

2. What hemplate exports

hemplate does not interpret data-hemx-*. It records raw facts:

Node {
    id: NodeId(12),
    element: "article",
    attrs: [
        ("class", "card"),
        ("data-hemx-slot", "card"),
        ("data-hemx-handle", "drag_card"),
        ("data-card-id", "{card.id}"),
        ("draggable", "true"),
    ],
    scope: ScopeId(For { binding: "card", key_expr: "card.id" }),
}

FormSurface {
    handle_attr: Some("create_card"),
    controls: [
        Control { name: "title", kind: Text, required: true },
        Control { name: "column", kind: Select, required: true },
    ],
}

hemx reads this from hemplate Surface facts and generates scoped typed resources:

use ui::board::{forms, targets};

targets::card.replace(card.id, &CardView::from(card));
forms::create_card.clear("title");
ui::render(&BoardView::from(board));

No string desync. No manual ids. The generated module owns the names.


3. App State

#[hemx::app]
pub struct BoardApp {
    pub board: Atom<BoardState>,
    pub drag: Atom<Option<DragState>>,
    pub online_users: Atom<Vec<UserPresence>>,
}

The same struct runs on server (SSR) and in WASM (client-local effects).


4. Normal Form: Server-first

#[derive(HemxForm)]
pub struct CreateCardForm {
    pub title: String,
    pub column: ColumnId,
}

#[hemx::handler]
pub fn create_card(
    form: Form<CreateCardForm>,
    app: &mut BoardApp,
) -> impl IntoEffect {
    let card = Card { id: CardId::new(), title: form.title, assignee: "Thomas".into() };

    app.board.update(|board| board.insert_card(form.column, card.clone()));

    (
        targets::card.append(card.id, &CardView::from(card)),
        forms::create_card.clear("title"),
        // hemx-sync: queue atomic board state diff for sync
        SyncEffect::send_patch(atoms::board, Patch::insert_card(form.column, card)),
    )
}

HTML submits as usual. Server returns a typed update batch. Browser applies DOM ops.


5. Drag: 60fps client-local WASM

#[hemx::handler(client)]
pub fn drag_card(
    event: DragEvent,
    app: &mut BoardApp,
) -> impl IntoEffect {
    app.drag.set(Some(DragState {
        card_id: event.card_id,
        from_column: event.column_id,
        pointer_x: event.x,
        pointer_y: event.y,
    }));

    // Client-local extension APIs stay typed by generated resources;
    // names below are illustrative until hemx-sync lands.
    Effect::batch((
        Effect::class_keyed(slots::CARD, event.card_id, "dragging", true),
        Effect::transform_keyed(
            slots::CARD,
            event.card_id,
            Transform::translate(event.x, event.y),
        ),
    ))
}

Zero round-trip. Zero custom JS. Pure Rust → typed updates → DOM.


6. Drop: optimistic update + sync

#[hemx::handler(client)]
pub fn drop_card(
    event: DropEvent,
    app: &mut BoardApp,
) -> impl IntoEffect {
    let patch = app.board.update(|board| {
        board.move_card(event.card_id, event.to_column, event.before_card)
    });

    app.drag.set(None);

    // Client-local extension APIs stay typed by generated resources;
    // names below are illustrative until hemx-sync lands.
    Effect::batch((
        Effect::move_keyed(
            slots::CARD,
            event.card_id,
            slots::COLUMN,
            event.to_column,
            InsertBefore(event.before_card),
        ),
        Effect::class_keyed(slots::CARD, event.card_id, "dragging", false),
        // hemx-sync: queue patch, send when online
        SyncEffect::send_patch(atoms::BOARD, patch),
    ))
}

A pure htmx+SSR app cannot model this: 60fps pointer → local transient drag → optimistic update → offline queue → reconciliation. You'd need custom JS or a parallel React/Vue layer.

hemx models it in one type graph.


7. Server reconciliation

#[hemx_sync::handler]
pub fn apply_board_patch(
    patch: BoardPatch,
    app: &mut BoardApp,
    user: UserId,
) -> impl IntoEffect {
    let result = app.board.update(|board| board.apply_patch_from(user, patch));

    match result {
        PatchResult::Accepted { changed_cards } => Effect::batch((
            Effect::ack(atoms::BOARD),
            Effect::broadcast(
                Channel::Board(app.board.id()),
                Effect::batch(changed_cards.into_iter().map(|c|
                    targets::card.replace(c.id, &CardView::from(c))
                )),
            ),
        )),

        PatchResult::Conflict { canonical_board } => Effect::batch((
            Effect::set(atoms::BOARD, canonical_board.clone()),
            targets::board.put(&BoardView::from(canonical_board)),
        )),
    }
}

Server-authoritative on conflict. No Redux sagas. No React Query cache fades.


8. Presence

#[hemx_sync::presence]
pub fn user_joined(user: UserPresence) -> impl IntoEffect {
    targets::presence_user.append(user.id, &PresenceBadge::from(user))
}

Browser receives typed update bytes over WebSocket/SSE:

append keyed presence user
remove keyed presence user

The runtime does not know "presence". It executes generated DOM updates.


9. What app authors write; what the browser receives

Initial SSR stays an ordinary rendered template with symbolic hemx attributes at the authoring boundary:

<section data-hemx-root="board" data-hemx-slot="board" data-hemx-atom="board">
    ...
    <article data-hemx-slot="card" data-hemx-handle="select_card" +data-card-id="card.id">
        {+ card.title +}
    </article>
    ...
</section>
<!-- the app shell loads the helper-provided runtime asset and any bootstrap state -->

The compiler lowers those symbols to compact runtime metadata, but that metadata is not an app-authoring contract. Runtime attachment: the helper-provided runtime asset installs delegated root listeners for forms, clicks, and pointer/drag events. App authors keep composing generated resources; they do not attach per-node listeners, copy numeric ids, or write selector glue.

No framework download. No VDOM. No hydration. No game loop.


10. Why this is not a React/Vue/htmx app

Concern React/Vue htmx+SSR hemx
SSR RSC/Vue SSR native native (hemplate)
60fps drag 100ms re-render + React-DnD custom JS WASM handler, typed update
Optimistic update useOptimistic impossible board.updateSyncEffect::send_patch
Offline support Service Worker + custom impossible patch queue in hemx-sync
Conflict resolution manual / Yjs CRDT impossible server-authoritative patch
Presence WebSocket + custom state SSE possible Effect::broadcast over channel
Keyed DOM React key not a concern KeyedSlot<K, T> compile-time
Forms React Hook Form HTML native, but no validation bridge Form<T> derived from .heml surface
Routing React Router / Vue Router HTML links, but no state routing Effect::navigate with scroll/title
Total JS shipped ~300KB+ ~20KB htmx + custom ~3KB hemx.js interpreter

11. The claim

A local-first multiplayer board where all high-frequency UI runs as Rust/WASM effects,
all durable state syncs through hemx-sync,
all HTML is hemplate-rendered,
and the browser runtime only executes typed postcard DOM ops.

Not:

server Rust here
client TypeScript there
shared schema somewhere
validation duplicated
DOM identity by positional DOM lookup
state sync by convention

But:

Rust owns types.
hemplate owns structure.
hemx owns interaction.
browser executes ops.