docs(kanban): show real hemplate composition syntax
Refresh the north-star kanban walkthrough to use current hemplate syntax plus lean generated render/slot/form helpers instead of stale sketch syntax and manual ids. req: dx/006 req: component/003
This commit is contained in:
+64
-67
@@ -11,66 +11,52 @@ This is the north-star integration test for slhx + hemplate + slhx-sync.
|
|||||||
## 1. Template: `board.heml`
|
## 1. Template: `board.heml`
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<section data-slhx-slot="board" data-slhx-atom="board">
|
<section data-slhx-root="board" data-slhx-slot="board" data-slhx-atom="board">
|
||||||
<header>
|
<header>
|
||||||
<h1>{self.title}</h1>
|
<h1>{+ self.title +}</h1>
|
||||||
|
|
||||||
<form data-slhx-handle="create_card">
|
<form data-slhx-handle="create_card" data-slhx-form="create_card">
|
||||||
<input name="title" type="text" required>
|
<input name="title" type="text" required>
|
||||||
<select name="column">
|
<select name="column">
|
||||||
@for column in self.columns key column.id {
|
<template h-for="column in &self.columns" h-key="column.id">
|
||||||
<option value="{column.id}">{column.title}</option>
|
<option +value="column.id">{+ column.title +}</option>
|
||||||
}
|
</template>
|
||||||
</select>
|
</select>
|
||||||
<button>Add card</button>
|
<button>Add card</button>
|
||||||
</form>
|
</form>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="columns" data-slhx-slot="columns">
|
<div class="columns" data-slhx-slot="columns">
|
||||||
@for column in self.columns key column.id {
|
<template h-for="column in &self.columns" h-key="column.id">
|
||||||
<section
|
<section class="column" data-slhx-slot="column" +data-column-id="column.id">
|
||||||
class="column"
|
<h2>{+ column.title +}</h2>
|
||||||
data-slhx-slot="column"
|
|
||||||
data-column-id="{column.id}"
|
|
||||||
>
|
|
||||||
<h2>{column.title}</h2>
|
|
||||||
|
|
||||||
<div
|
<div class="cards" +data-column-id="column.id">
|
||||||
class="cards"
|
<template h-for="card in &column.cards" h-key="card.id">
|
||||||
data-slhx-dropzone="column"
|
<article class="card" data-slhx-slot="card" data-slhx-handle="drag_card" +data-card-id="card.id" draggable="true">
|
||||||
data-column-id="{column.id}"
|
<strong>{+ card.title +}</strong>
|
||||||
>
|
<small>{+ card.assignee +}</small>
|
||||||
@for card in column.cards key card.id {
|
</article>
|
||||||
<article
|
</template>
|
||||||
class="card"
|
</div>
|
||||||
data-slhx-slot="card"
|
</section>
|
||||||
data-slhx-handle="drag_card"
|
</template>
|
||||||
data-card-id="{card.id}"
|
</div>
|
||||||
draggable="true"
|
|
||||||
>
|
|
||||||
<strong>{card.title}</strong>
|
|
||||||
<small>{card.assignee}</small>
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside data-slhx-slot="presence">
|
<aside data-slhx-slot="presence">
|
||||||
@for user in self.online_users key user.id {
|
<template h-for="user in &self.online_users" h-key="user.id">
|
||||||
<span>{user.name}</span>
|
<span>{+ user.name +}</span>
|
||||||
}
|
</template>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes on keyed scopes:
|
Notes on keyed scopes:
|
||||||
|
|
||||||
- `@for column key column.id` — **required** for slhx-addressable nodes inside
|
- `h-for="column in &self.columns" h-key="column.id"` — **required** for slhx-addressable nodes inside
|
||||||
- `@for card key card.id` — **required**
|
- `h-for="card in &column.cards" h-key="card.id"` — **required**
|
||||||
- A slot inside a keyed loop is addressed as `(SlotId, KeyValue)`, never `querySelector(".card:nth-child(3)")`
|
- A slot inside a keyed loop is addressed as a generated keyed resource, never `querySelector(".card:nth-child(3)")`
|
||||||
- Without `key`, slhx rejects the build — no runtime selector fallback
|
- Without `h-key`, slhx rejects the build — no runtime selector fallback
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,14 +87,17 @@ FormSurface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
slhx reads this from `hemplate.surface.postcard` and generates typed constants:
|
slhx reads this from hemplate Surface facts and generates scoped typed resources:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub const CARD: KeyedSlot<CardId, CardView> = KeyedSlot::new(3);
|
use ui::board::{forms, slots};
|
||||||
pub const CREATE_CARD: Handle<CreateCardForm> = Handle::new(0);
|
|
||||||
|
slots::card.replace(card.id, CardView::from(card));
|
||||||
|
forms::create_card.clear("title");
|
||||||
|
ui::render(&BoardView::from(board));
|
||||||
```
|
```
|
||||||
|
|
||||||
No string desync. No runtime mapping.
|
No string desync. No manual ids. The generated module owns the names.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,11 +134,12 @@ pub fn create_card(
|
|||||||
|
|
||||||
app.board.update(|board| board.insert_card(form.column, card.clone()));
|
app.board.update(|board| board.insert_card(form.column, card.clone()));
|
||||||
|
|
||||||
Effect::batch((
|
(
|
||||||
Effect::append_keyed(slots::CARD, card.id, CardView::from(card)),
|
slots::card.append(card.id, &CardView::from(card)),
|
||||||
|
forms::create_card.clear("title"),
|
||||||
// slhx-sync: queue atomic board state diff for sync
|
// slhx-sync: queue atomic board state diff for sync
|
||||||
SyncEffect::send_patch(atoms::BOARD, Patch::insert_card(form.column, card)),
|
SyncEffect::send_patch(atoms::board, Patch::insert_card(form.column, card)),
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -172,10 +162,13 @@ pub fn drag_card(
|
|||||||
pointer_y: event.y,
|
pointer_y: event.y,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Client-local extension APIs stay typed by generated resources;
|
||||||
|
// names below are illustrative until slhx-sync lands.
|
||||||
Effect::batch((
|
Effect::batch((
|
||||||
Effect::class_keyed(slots::CARD, event.card_id, "dragging", true),
|
Effect::class_keyed(slots::CARD, event.card_id, "dragging", true),
|
||||||
Effect::transform_keyed(
|
Effect::transform_keyed(
|
||||||
slots::CARD, event.card_id,
|
slots::CARD,
|
||||||
|
event.card_id,
|
||||||
Transform::translate(event.x, event.y),
|
Transform::translate(event.x, event.y),
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
@@ -200,10 +193,14 @@ pub fn drop_card(
|
|||||||
|
|
||||||
app.drag.set(None);
|
app.drag.set(None);
|
||||||
|
|
||||||
|
// Client-local extension APIs stay typed by generated resources;
|
||||||
|
// names below are illustrative until slhx-sync lands.
|
||||||
Effect::batch((
|
Effect::batch((
|
||||||
Effect::move_keyed(
|
Effect::move_keyed(
|
||||||
slots::CARD, event.card_id,
|
slots::CARD,
|
||||||
slots::COLUMN, event.to_column,
|
event.card_id,
|
||||||
|
slots::COLUMN,
|
||||||
|
event.to_column,
|
||||||
InsertBefore(event.before_card),
|
InsertBefore(event.before_card),
|
||||||
),
|
),
|
||||||
Effect::class_keyed(slots::CARD, event.card_id, "dragging", false),
|
Effect::class_keyed(slots::CARD, event.card_id, "dragging", false),
|
||||||
@@ -236,14 +233,14 @@ pub fn apply_board_patch(
|
|||||||
Effect::broadcast(
|
Effect::broadcast(
|
||||||
Channel::Board(app.board.id()),
|
Channel::Board(app.board.id()),
|
||||||
Effect::batch(changed_cards.into_iter().map(|c|
|
Effect::batch(changed_cards.into_iter().map(|c|
|
||||||
Effect::replace_keyed(slots::CARD, c.id, CardView::from(c))
|
slots::card.replace(c.id, &CardView::from(c))
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
|
|
||||||
PatchResult::Conflict { canonical_board } => Effect::batch((
|
PatchResult::Conflict { canonical_board } => Effect::batch((
|
||||||
Effect::set(atoms::BOARD, canonical_board.clone()),
|
Effect::set(atoms::BOARD, canonical_board.clone()),
|
||||||
Effect::render(slots::BOARD, BoardView::from(canonical_board)),
|
slots::board.render(&BoardView::from(canonical_board)),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +255,7 @@ Server-authoritative on conflict. No Redux sagas. No React Query cache fades.
|
|||||||
```rust
|
```rust
|
||||||
#[slhx_sync::presence]
|
#[slhx_sync::presence]
|
||||||
pub fn user_joined(user: UserPresence) -> impl IntoEffect {
|
pub fn user_joined(user: UserPresence) -> impl IntoEffect {
|
||||||
Effect::append_keyed(slots::PRESENCE_USER, user.id, PresenceBadge::from(user))
|
slots::presence_user.append(user.id, &PresenceBadge::from(user))
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -315,8 +312,8 @@ No framework download. No VDOM. No hydration. No game loop.
|
|||||||
| Offline support | Service Worker + custom | impossible | patch queue in `slhx-sync` |
|
| Offline support | Service Worker + custom | impossible | patch queue in `slhx-sync` |
|
||||||
| Conflict resolution | manual / Yjs CRDT | impossible | server-authoritative patch |
|
| Conflict resolution | manual / Yjs CRDT | impossible | server-authoritative patch |
|
||||||
| Presence | WebSocket + custom state | SSE possible | `Effect::broadcast` over channel |
|
| Presence | WebSocket + custom state | SSE possible | `Effect::broadcast` over channel |
|
||||||
| Keyed DOM | React key | not a concern | `KeydSlot<T, K>` compile-time |
|
| Keyed DOM | React key | not a concern | `KeyedSlot<K, T>` compile-time |
|
||||||
| Forms | React Hook Form | HTML native, but no validation bridge | `Fork<T>` derived from `.heml` surface |
|
| 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 |
|
| 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 |
|
| Total JS shipped | ~300KB+ | ~20KB htmx + custom | ~3KB slhx.js interpreter |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user