feat: slhx requirements, project structure, and core primitives
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
# 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-slot="board" data-slhx-atom="board">
|
||||
<header>
|
||||
<h1>{self.title}</h1>
|
||||
|
||||
<form data-slhx-handle="create_card">
|
||||
<input name="title" type="text" required>
|
||||
<select name="column">
|
||||
@for column in self.columns key column.id {
|
||||
<option value="{column.id}">{column.title}</option>
|
||||
}
|
||||
</select>
|
||||
<button>Add card</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div class="columns" data-slhx-slot="columns">
|
||||
@for column in self.columns key column.id {
|
||||
<section
|
||||
class="column"
|
||||
data-slhx-slot="column"
|
||||
data-column-id="{column.id}"
|
||||
>
|
||||
<h2>{column.title}</h2>
|
||||
|
||||
<div
|
||||
class="cards"
|
||||
data-slhx-dropzone="column"
|
||||
data-column-id="{column.id}"
|
||||
>
|
||||
@for card in column.cards 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>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
<aside data-slhx-slot="presence">
|
||||
@for user in self.online_users key user.id {
|
||||
<span>{user.name}</span>
|
||||
}
|
||||
</aside>
|
||||
</section>
|
||||
```
|
||||
|
||||
Notes on keyed scopes:
|
||||
|
||||
- `@for column key column.id` — **required** for slhx-addressable nodes inside
|
||||
- `@for card key card.id` — **required**
|
||||
- A slot inside a keyed loop is addressed as `(SlotId, KeyValue)`, never `querySelector(".card:nth-child(3)")`
|
||||
- Without `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.postcard` and generates typed constants:
|
||||
|
||||
```rust
|
||||
pub const CARD: KeyedSlot<CardId, CardView> = KeyedSlot::new(3);
|
||||
pub const CREATE_CARD: Handle<CreateCardForm> = Handle::new(0);
|
||||
```
|
||||
|
||||
No string desync. No runtime mapping.
|
||||
|
||||
---
|
||||
|
||||
## 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()));
|
||||
|
||||
Effect::batch((
|
||||
Effect::append_keyed(slots::CARD, card.id, CardView::from(card)),
|
||||
// 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,
|
||||
}));
|
||||
|
||||
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);
|
||||
|
||||
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|
|
||||
Effect::replace_keyed(slots::CARD, c.id, CardView::from(c))
|
||||
)),
|
||||
),
|
||||
)),
|
||||
|
||||
PatchResult::Conflict { canonical_board } => Effect::batch((
|
||||
Effect::set(atoms::BOARD, canonical_board.clone()),
|
||||
Effect::render(slots::BOARD, 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 {
|
||||
Effect::append_keyed(slots::PRESENCE_USER, 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-sid="0" data-aid="0">
|
||||
...
|
||||
<article data-sid="3" data-sk="42" data-hid="1">
|
||||
Fix login bug
|
||||
</article>
|
||||
...
|
||||
</section>
|
||||
<script src="/slhx.js"></script>
|
||||
<script type="application/slhx-state">
|
||||
BASE64URL_POSTCARD_INITIAL_ATOMS
|
||||
</script>
|
||||
```
|
||||
|
||||
Runtime attachment:
|
||||
|
||||
```js
|
||||
document.addEventListener("submit", dispatch)
|
||||
document.addEventListener("click", dispatch)
|
||||
document.addEventListener("pointerdown", dispatch)
|
||||
document.addEventListener("pointermove", dispatch)
|
||||
document.addEventListener("pointerup", dispatch)
|
||||
```
|
||||
|
||||
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 | `KeydSlot<T, K>` compile-time |
|
||||
| Forms | React Hook Form | HTML native, but no validation bridge | `Fork<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.
|
||||
```
|
||||
Reference in New Issue
Block a user