Files
hemx/examples/kanban.md
T
slhx agent a1e3085437 docs(kanban): describe delegated runtime attachment
Replace the north-star walkthrough's listener snippet with prose and guard example docs against teaching addEventListener glue.

req: examples/003

req: runtime/001
2026-06-02 01:56:10 +02:00

341 lines
9.2 KiB
Markdown

# 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
<section data-slhx-root="board" data-slhx-slot="board" data-slhx-atom="board">
<header>
<h1>{+ self.title +}</h1>
<form data-slhx-handle="create_card" data-slhx-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-slhx-slot="columns">
<template h-for="column in &self.columns" h-key="column.id">
<section class="column" data-slhx-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-slhx-slot="card" data-slhx-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-slhx-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 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 `querySelector(".card:nth-child(3)")`
- 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<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
```rust
#[derive(SlhxForm)]
pub struct CreateCardForm {
pub title: String,
pub column: ColumnId,
}
#[slhx::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()));
(
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
<section data-slhx-root data-sid="0" data-aid="0">
...
<article data-sid="3" data-key="42" data-hid="1">
Fix login bug
</article>
...
</section>
<!-- the app shell loads /slhx.js and any bootstrap state -->
```
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<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 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.
```