# 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 the north-star integration test for slhx + hemplate + slhx-sync. --- ## 1. Template: `board.heml` ```html

{+ self.title +}

``` Notes on keyed scopes: - `h-for="column in &self.columns" h-key="column.id"` — **required** for slhx-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`, slhx rejects the build — no runtime selector fallback --- ## 2. What hemplate exports hemplate does **not** interpret `data-slhx-*`. It records raw facts: ```rust Node { id: NodeId(12), element: "article", attrs: [ ("class", "card"), ("data-slhx-slot", "card"), ("data-slhx-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 }, ], } ``` slhx reads this from hemplate Surface facts and generates scoped typed resources: ```rust use ui::board::{forms, slots}; slots::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 ```rust #[slhx::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(SlhxForm)] pub struct CreateCardForm { pub title: String, pub column: ColumnId, } #[slhx::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())); ( slots::card.append(card.id, &CardView::from(card)), forms::create_card.clear("title"), // slhx-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 `EffectBatch`. Browser applies DOM ops. --- ## 5. Drag: 60fps client-local WASM ```rust #[slhx::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 slhx-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 → EffectBatch → DOM. --- ## 6. Drop: optimistic update + sync ```rust #[slhx::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 slhx-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), // slhx-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. slhx models it in one type graph. --- ## 7. Server reconciliation ```rust #[slhx_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| slots::card.replace(c.id, &CardView::from(c)) )), ), )), PatchResult::Conflict { canonical_board } => Effect::batch(( Effect::set(atoms::BOARD, canonical_board.clone()), slots::board.html(ui::render(&BoardView::from(canonical_board))), )), } } ``` Server-authoritative on conflict. No Redux sagas. No React Query cache fades. --- ## 8. Presence ```rust #[slhx_sync::presence] pub fn user_joined(user: UserPresence) -> impl IntoEffect { slots::presence_user.append(user.id, &PresenceBadge::from(user)) } ``` Browser receives raw `EffectBatch` over WebSocket/SSE: ```text Op::AppendKeyed(slot=PRESENCE_USER, key=user_id, html=...) Op::RemoveKeyed(slot=PRESENCE_USER, key=user_id) ``` The runtime does not know "presence". It executes ops. --- ## 9. What the browser receives Initial SSR: ```html
...
Fix login bug
...
``` Runtime attachment: `/slhx.js` installs delegated root listeners for forms, clicks, and pointer/drag events. App authors keep composing generated resources; they do not attach per-node listeners 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 | slhx | |---|---|---|---| | SSR | RSC/Vue SSR | native | native (hemplate) | | 60fps drag | 100ms re-render + React-DnD | custom JS | WASM handler, EffectBatch | | Optimistic update | useOptimistic | impossible | `board.update` → `SyncEffect::send_patch` | | Offline support | Service Worker + custom | impossible | patch queue in `slhx-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 slhx.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 slhx-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 selectors state sync by convention ``` But: ```text Rust owns types. hemplate owns structure. slhx owns interaction. browser executes ops. ```