# 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. req: milestone/001 req: milestone/002 req: milestone/003 --- ## 1. Template: `board.heml` ```html

{+ self.title +}

``` 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: ```rust 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: ```rust use ui::board::{forms, targets}; targets::card.replace(card.id, &CardView::from(card)); forms::create_card.clear("title"); ui::page(&BoardView::from(board)); ``` No string desync. No manual ids. The generated module owns the names. --- ## 3. App State ```rust #[hemx::app] pub struct BoardApp { pub board: Atom, pub drag: Atom>, pub online_users: Atom>, } ``` The same struct runs on server (SSR) and in WASM (client-local effects). --- ## 4. Normal Form: Server-first ```rust #[derive(HemxForm)] pub struct CreateCardForm { pub title: String, pub column: ColumnId, } #[hemx::handler] pub fn create_card( form: Form, 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 ```rust #[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 ```rust #[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 ```rust #[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 ```rust #[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: ```text 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: ```html
...
{+ card.title +}
...
``` 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.update` → `SyncEffect::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` compile-time | | Forms | React Hook Form | HTML native, but no validation bridge | `Form` 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 ```text 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: ```text server Rust here client TypeScript there shared schema somewhere validation duplicated DOM identity by positional DOM lookup state sync by convention ``` But: ```text Rust owns types. hemplate owns structure. hemx owns interaction. browser executes ops. ```