feat: slhx requirements, project structure, and core primitives

This commit is contained in:
2026-05-10 11:33:26 +02:00
commit bd13d0fa0d
7 changed files with 1045 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
msg_file="$1"
# Require scope if REQs exist
if [ -f REQUIREMENTS.md ]; then
if ! grep -qE '^[a-z]+(\(.+\))?:' "$msg_file"; then
echo "error: commit requires scope — e.g. feat(parser): ..."
exit 1
fi
fi
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
changed=$(git diff --cached --name-only)
# fail if REQs changed but AGENTS.md is older
if echo "$changed" | grep -q '^REQUIREMENTS.md$' && echo "$changed" | grep -q '^AGENTS.md$'; then
# Both changed — OK
:
else
req_time=$(git log -1 --format=%ct -- REQUIREMENTS.md 2>/dev/null || echo 0)
ag_time=$(git log -1 --format=%ct -- AGENTS.md 2>/dev/null || echo 0)
if [ "$req_time" -gt "$ag_time" ]; then
echo "error: REQUIREMENTS.md newer than AGENTS.md — run: redgate agents > AGENTS.md"
exit 1
fi
fi
+12
View File
@@ -0,0 +1,12 @@
# Tool Registry
| Tool | Description |
|------|-------------|
| redgate | Requirements-first governance: list, refs, health, agents |
## redgate usage
- `redgate list` — TSV of all requirements
- `redgate refs` — find req: citations in source
- `redgate health` — ok/uncited per requirement
- `redgate agents` — render AGENTS.md from REQUIREMENTS.md
+280
View File
@@ -0,0 +1,280 @@
# — AGENTS.md
> Auto-generated from REQUIREMENTS.md. Do not edit directly.
> Edit REQUIREMENTS.md and run: redgate agents > AGENTS.md
## Requirements
### req:_boundary/001
- **001** hemplate does not expose a slhx API. It exposes a stable, generic Template Surface IR. slhx is one consumer; a11y tools, test generators, and documentation generators are others.
### req:_boundary/002
- **002** hemplate never interprets `data-slhx-*`, `slhx-*`, or any other tool-prefixed attribute. It records them as raw `name: value` pairs in the Surface.
### req:_boundary/003
- **003** slhx never parses `.heml` directly. It consumes `hemplate.surface.postcard` emitted by `hemplate_build`. slhx interprets tool-specific conventions (`data-slhx-handle`, `data-slhx-slot`, etc.) from the generic Surface.
### req:_build/001
- **001** Build order: `.heml``hemplate_build``hemplate.surface.postcard``slhx_build``slhx.syms` + generated Rust constants.
### req:_build/002
- **002** `slhx-derive` (`#[slhx::handler]`) reads `slhx.syms` at expansion time to validate handle names, slot names, and form signatures.
### req:_build/003
- **003** A `build.rs` failure (missing Surface, version mismatch, stale hash) is a hard error before proc-macro expansion.
### req:_build/004
- **004** Id allocation is deterministic from canonical symbol paths. Stable across builds unless the symbol path changes.
### req:_check/001
- **001** All cross-file references verified at `cargo check`. Unknown handle → hard error. Unknown slot → hard error. Type mismatch between slot and atom → hard error.
### req:_check/002
- **002** Dead handle warning: `#[slhx::handler]` never referenced by any template. Dead slot warning: template node never targeted by any handler.
### req:_check/003
- **003** Renaming a slot or handle breaks `cargo check` immediately with a span pointing to the Rust handler or template source.
### req:_check/004
- **004** Page-scoped slot lookup: JS runtime resolves `data-sid` only within the current Page root element.
### req:_derive_app/001
- **001** `#[slhx::app]` marks the root application struct containing all global atoms. It is the registry entry point for `slhx_build`. Zero- or single-instance per process.
### req:_effect/001
- **001** Effects are a typed command stream describing *what*, *where*, and *how* of a DOM mutation. Declarative: Rust builds the stream; JS applies it.
### req:_effect/002
- **002** The public API is `IntoEffect` (a trait) for Rust ergonomics and zero-allocation encoding. The wire API is a canonical postcard opcode schema (`Op::ReplaceHtml`, `Op::SetText`, `Op::PatchAtom`, `Op::Navigate`, `Op::Focus`, `Op::AddClass`, `Op::RemoveClass`, `Op::RemoveKeyed`, `Op::CustomOp`). IntoEffect writes opcodes directly; advanced users may implement the trait to stream custom opcodes.
### req:_effect/003
- **003** Core effect helpers: `replace(slot, html)`, `patch(slot, atom)`, `text(slot, value)`, `remove_keyed(slot, key)`, `append_keyed(slot, key, html)`, `move_keyed(slot, key, target_slot, target_key, position)`, `class_keyed(slot, key, class, active)`, `navigate(Nav { url, mode, title, scroll })`, `focus(slot)`, `add_class(slot, class)`, `remove_class(slot, class)`, `batch((...))`.
### req:_effect/004
- **004** `batch` composes effects in declared order. No implicit ordering, no priority weights.
### req:_effect/005
- **005** `Effect::navigate` carries `NavMode::Push | Replace | Redirect`, optional `title`, and `ScrollMode`. No separate router framework required for basic cases.
### req:_effect/006
- **006** Core effects may carry an opaque transition token, but core never interprets or implements transitions. Transitions live in `slhx-transition`.
### req:_effect/007
- **007** Zero runtime parsing of selectors. The JS runtime looks up elements by numeric `data-sid` or `data-hid` attributes. Slot ids are allocated deterministically.
### req:_form/001
- **001** Forms are first-class. hemplate exports `FormSurface` with raw `ControlKind` facts. slhx generates/validates Rust form structs from those facts.
### req:_form/002
- **002** `Form<T>` is generated by slhx-build from the Surface. `T` derives from HTML control names and kinds, mapped to Rust types by slhx rules (e.g. `type="number"` + `required``u64`; same without `required``Option<u64>`).
### req:_form/003
- **003** Handler signature mismatch between generated `Form<T>` and the handler parameter is a `cargo check` error.
### req:_form/004
- **004** Progressive enhancement: if JS fails, `<form data-slhx-handle>` degrades to normal submission via hidden `__h` field. Server reads `__h` and dispatches by numeric handle id.
### req:_invariant/001
- **001** User-authored references are symbolic at author time and numeric at runtime.
### req:_invariant/002
- **002** The JS runtime never parses CSS selectors, expressions, or handler names.
### req:_invariant/003
- **003** Rust handlers return effects; they do not imperatively mutate DOM.
### req:_invariant/004
- **004** Cross-file references fail at `cargo check` with a precise span.
### req:_invariant/005
- **005** slhx core owns effects, typed ids, and registries only. Routing, auth, sessions, transport, transitions, and sync are integration concerns.
### req:_js/001
- **001** The JS runtime is a single file under 3 kB minified+gzipped. It reads `data-hid` and `data-sid`, delegates events on `document`, and applies effects by direct DOM mutation.
### req:_js/002
- **002** No build step, no virtual DOM, no diffing, no scheduler. Receiving an effect = apply ops immediately in declared order.
### req:_js/003
- **003** The runtime consists of an Op interpreter (`ReplaceHtml`, `SetText`, `PatchAtom`, `Navigate`, `Focus`, `AddClass`, `RemoveClass`, `RemoveKeyed`, `CustomOp`) reading from a postcard byte stream.
### req:_list/001
- **001** Any `data-slhx-slot` or `data-slhx-handle` inside `@for` requires an explicit `key` expression. Syntax: `@for item in items key item.id { ... }`. Without `key`, slhx-addressable nodes inside the loop are rejected at build time. Keyed identity is `(SlotId, KeyValue)`.
### req:_list/002
- **002** Slots inside a keyed loop receive a composite identity: `(SlotId, KeyValue)`, not a flat id. hemplate records `key_expr` in the Surface; slhx implements keyed slot lookups.
### req:_list/003
- **003** Effects on keyed slots: `replace_keyed(slot, key, value)`, `remove_keyed(slot, key)`, `append_keyed(slot, key, value)`. Mismatch between key type and slot key type is compile-time error.
### req:_misc/001
- **001** Workspace layout: `slhx-core` (types + postcard schema, no_std), `slhx-derive` (proc-macros), `slhx-build` (surface consumer + code generation), `slhx-axum` (integration), `slhx-js` (runtime single file), `slhx-transition` (optional), `slhx-sync` (optional), `slhx-wasm` (optional). No kitchen-sink crate.
### req:_misc/002
- **002** All crates compile on stable Rust. MSRV 1.80. `slhx-core` has zero proc-macro dependencies.
### req:_misc/003
- **003** No auth, no routing, no session storage inside slhx core. `slhx-axum` provides typed route mounting; actual routing is axum/tower.
### req:_misc/004
- **004** Three execution modes supported: server-first (request/response), client-local WASM (requestAnimationFrame, no round-trip), and hybrid sync (local + remote via `slhx-sync`). Modes are opt-in per handler, not global.
### req:_misc/005
- **005** The only user-facing proc-macro is `#[slhx::handler]`. No `!` call-syntax macros. Attribute macros only.
### req:_ms/001
- **001** **Milestone app: 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 acts as the north-star integration test for slhx + hemplate + slhx-sync. [north_star]
### req:_pitch/001
- **001** slhx is checked hypermedia for Rust. Authors write HTML templates and Rust handlers. The compiler lowers every cross-file reference to a stable numeric id. The browser runtime only sees ids and effect bytes. [north_star]
### req:_pitch/002
- **002** No CSS selectors. No hx-* strings. No virtual DOM. No client framework. No hidden global proxy magic. No hydration. [north_star]
### req:_pitch/003
- **003** slhx replaces React/Vue not with a UI framework, but with a compiler contract: hemplate knows the surface, Rust knows the types, slhx knows the effects, the browser only executes commands. [north_star]
### req:_resource/001
- **001** Async remote data lives in `Resource<T>` / `Query<K, T>` / `Mutation<I, O>`. These are optional, not core primitives. They provide loading/error/refresh semantics without client-side data libraries.
### req:_resource/002
- **002** `Effect::resource(res).reload()` triggers a re-fetch and re-render. The server sends a new EffectBatch when data is ready.
### req:_state/001
- **001** `Atom<T>` is a typed, stable-id handle to a piece of state. Atoms are the only state primitive in core.
### req:_state/002
- **002** State shape is flat. Nesting is an anti-pattern; compose via multiple atoms.
### req:_state/003
- **003** No proxy magic. State access is explicit: `store.get(atom)` returns `Option<&T>`. Mutations return `impl IntoEffect`, not side-effects.
### req:_state/004
- **004** Page-local transient state lives in JS as `Map<AtomId, unknown>`. Not reactive-by-default.
### req:_state/005
- **005** Global long-lived state is stored server-side in a session-compatible way. On re-render the server injects a `postcard`-encoded blob in `<script type="application/slhx-state">`; JS hydrates it so client-side handlers (WASM-compiled Rust) can read it without round-trips.
### req:_state/006
- **006** The atom model is isomorphic to ECS. Atoms are components, Pages are worlds, Slots are entities. Scales to game-like WASM applications.
### req:_surface/001
- **001** `hemplate_build` scans `.heml` files and emits `$OUT_DIR/hemplate.surface.postcard` (postcard-encoded, deterministic, versioned).
### req:_surface/002
- **002** The Surface contains: nodes (NodeId, parent, scope, element, attrs, source span), scopes (ScopeKind: Root | If | For { binding, key_expr }), forms (form controls with raw HTML types), and component uses.
### req:_surface/003
- **003** Node identity is `NodeId` in a parent/scope graph. No `css_path` is used as a primary identifier. An optional `debug_path` string may exist for diagnostics only.
### req:_surface/004
- **004** Form controls in the Surface carry raw HTML facts: `ControlKind::Text`, `ControlKind::Number { min, max, step }`, `ControlKind::Checkbox`, `ControlKind::Select { multiple, options }`, etc. No Rust type mapping lives in hemplate.
### req:_surface/005
- **005** Loop scopes expose the binding name and an optional `key_expr` (e.g. `todo.id`). hemplate does not enforce key usage; it only records it for consumers.
### req:_surface/006
- **006** Surface schema is versioned (`schema_version: u32`). Postcard encoding, no JSON. `no_std`-compatible schema definition so any tool can read it without heavy dependencies.
### req:_surface/007
- **007** `hemplate-derive` does not write Surface files. Surface generation is a `build.rs` / `hemplate_build` concern, proc-macro side-effect free.
### req:_sync/001
- **001** `slhx-sync` is an optional crate for collaborative / multiplayer state. Provides presence tracking, patch reconciliation, conflict resolution (server-authoritative), and offline queueing. Not part of core.
### req:_sync/002
- **002** `SyncEffect::send_patch(atom, patch)` queues a state diff for server sync. If offline, the patch is stored in a local queue and sent when connection resumes. If online, it is sent immediately via WebSocket/SSE.
### req:_sync/003
- **003** `Effect::ack(atom)` acknowledges a successful server-side mutation, allowing the client to clear its local optimistic queue for that atom.
### req:_sync/004
- **004** `Effect::broadcast(channel, effect_batch)` sends an `EffectBatch` to all subscribers of a named channel. Used for presence updates and live collaboration. The server framework manages the transport (WS/SSE).
### req:_sync/005
- **005** `#[slhx_sync::presence]` is an attribute macro on functions that return `impl IntoEffect` when a user joins or leaves a shared session. Emits `Effect::broadcast` over a presence channel scoped to the session.
### req:_sync/006
- **006** `slhx-sync` uses a flat patch model per atom, not CRDT by default. Server is authoritative; clients apply server-canonical state on conflict. Optional CRDT backend may be provided by a future `slhx-crdt` crate.
### req:_wire/001
- **001** HTML wire format is standard HTML with `data-hid` and `data-sid` attributes only. No custom markup, no hx-* attributes.
### req:_wire/002
- **002** POST bodies carry `application/x-www-form-urlencoded` with distinguished field `__h` (handle id). Server routes by numeric id, not by URL path.
### req:_wire/003
- **003** Responses are `text/html` fragments (or `application/slhx` for push streams). Fragments may contain `<template data-slhx>` elements whose text content is a base64url-encoded postcard `EffectBatch`. JS decodes and applies.
### req:_wire/004
- **004** Server push is supported orthogonally: `Effect::push(stream, effect)` sends a pre-serialized effect batch over an SSE or WebSocket connection. Connection management is a server-framework concern.
## Coverage: 0/68 (100.0% uncited)
+11
View File
@@ -0,0 +1,11 @@
[workspace]
resolver = "2"
members = ["slhx-core", "slhx-derive", "slhx-js", "slhx-axum"]
[workspace.package]
version = "0.1.0"
edition = "2021"
[profile.release]
opt-level = "z"
lto = true
+365
View File
@@ -0,0 +1,365 @@
# slhx — Semantic, Laterally HX
slhx does not compete with React by becoming a better frontend framework.
slhx competes with React by making frontend frameworks unnecessary for most apps.
> **hemplate owns syntax. hemplate emits surface. slhx consumes surface. slhx owns semantics. JS executes bytecode.**
---
## pitch
### req: pitch/001
001 slhx is checked hypermedia for Rust. Authors write HTML templates and Rust handlers. The compiler lowers every cross-file reference to a stable numeric id. The browser runtime only sees ids and effect bytes. [north_star]
### req: pitch/002
002 No CSS selectors. No hx-* strings. No virtual DOM. No client framework. No hidden global proxy magic. No hydration. [north_star]
### req: pitch/003
003 slhx replaces React/Vue not with a UI framework, but with a compiler contract: hemplate knows the surface, Rust knows the types, slhx knows the effects, the browser only executes commands. [north_star]
---
## invariant
### req: invariant/001
001 User-authored references are symbolic at author time and numeric at runtime.
### req: invariant/002
002 The JS runtime never parses CSS selectors, expressions, or handler names.
### req: invariant/003
003 Rust handlers return effects; they do not imperatively mutate DOM.
### req: invariant/004
004 Cross-file references fail at `cargo check` with a precise span.
### req: invariant/005
005 slhx core owns effects, typed ids, and registries only. Routing, auth, sessions, transport, transitions, and sync are integration concerns.
---
## boundary
### req: boundary/001
001 hemplate does not expose a slhx API. It exposes a stable, generic Template Surface IR. slhx is one consumer; a11y tools, test generators, and documentation generators are others.
### req: boundary/002
002 hemplate never interprets `data-slhx-*`, `slhx-*`, or any other tool-prefixed attribute. It records them as raw `name: value` pairs in the Surface.
### req: boundary/003
003 slhx never parses `.heml` directly. It consumes `hemplate.surface.postcard` emitted by `hemplate_build`. slhx interprets tool-specific conventions (`data-slhx-handle`, `data-slhx-slot`, etc.) from the generic Surface.
---
## surface
### req: surface/001
001 `hemplate_build` scans `.heml` files and emits `$OUT_DIR/hemplate.surface.postcard` (postcard-encoded, deterministic, versioned).
### req: surface/002
002 The Surface contains: nodes (NodeId, parent, scope, element, attrs, source span), scopes (ScopeKind: Root | If | For { binding, key_expr }), forms (form controls with raw HTML types), and component uses.
### req: surface/003
003 Node identity is `NodeId` in a parent/scope graph. No `css_path` is used as a primary identifier. An optional `debug_path` string may exist for diagnostics only.
### req: surface/004
004 Form controls in the Surface carry raw HTML facts: `ControlKind::Text`, `ControlKind::Number { min, max, step }`, `ControlKind::Checkbox`, `ControlKind::Select { multiple, options }`, etc. No Rust type mapping lives in hemplate.
### req: surface/005
005 Loop scopes expose the binding name and an optional `key_expr` (e.g. `todo.id`). hemplate does not enforce key usage; it only records it for consumers.
### req: surface/006
006 Surface schema is versioned (`schema_version: u32`). Postcard encoding, no JSON. `no_std`-compatible schema definition so any tool can read it without heavy dependencies.
### req: surface/007
007 `hemplate-derive` does not write Surface files. Surface generation is a `build.rs` / `hemplate_build` concern, proc-macro side-effect free.
---
## build
### req: build/001
001 Build order: `.heml``hemplate_build``hemplate.surface.postcard``slhx_build``slhx.syms` + generated Rust constants.
### req: build/002
002 `slhx-derive` (`#[slhx::handler]`) reads `slhx.syms` at expansion time to validate handle names, slot names, and form signatures.
### req: build/003
003 A `build.rs` failure (missing Surface, version mismatch, stale hash) is a hard error before proc-macro expansion.
---
## effect
### req: effect/001
001 Rust handlers return `impl IntoEffect`, not a concrete `Vec<Effect>`. `IntoEffect::encode(self, &mut EffectWriter)` may write directly into a response buffer, a test collector, or an event stream. Zero-allocation encoding is possible; allocation is not required.
### req: effect/002
002 `EffectWriter` has a fixed canonical op set: `Set`, `Patch`, `Insert`, `Remove`, `Move`, `Focus`, `Navigate`, `Emit`. The DOM is one backend; core does not hardcode DOM operations. Wire format is canonical postcard opcodes; Rust API is flexible.
### req: effect/003
003 Multiple effects are combined with `Effect::batch((...))` or a method chain on `EffectWriter`. No `!` call-syntax macros.
### req: effect/004
004 `Effect::render(slot, value)` is sugar for `Effect::set` on a Slot resource. `Effect::text(slot, value)` is sugar for `Effect::set` with a text payload.
### req: effect/005
005 effects may carry an opaque transition token, but core never interprets or implements transitions. `slhx-transition` provides transition semantics as an orthogonal integration.
### req: effect/006
006 `Effect::event(name, payload)` dispatches a native `CustomEvent` on the root element. Core interprets the payload as opaque bytes. Web Components, charts, editors, or legacy JS may listen without slhx knowing about them. [north_star]
---
## state
### req: state/001
001 Typed atoms with `Atom<T>`: read via `atom.get()`, subscribe via `Effect::set`. No hidden global proxy / reactive graph. Atoms are explicit values in `struct App`.
### req: state/002
002 Subscription is explicit: `Effect::set(atoms::FOO, 42)` pushes the new value to all consumers. No automatic component re-render graph.
### req: state/003
003 JS runtime maintains a client-side atom store with identical API to the server: `get<T>(atom)`, `set<T>(atom, value)`, `subscribe<T>(atom, callback)`. Type erased at runtime with `TypeId`.
### req: state/004
004 SSR pages carry a `data-slhx-st` base64url-encoded postcard blob on the document root. Runtime decodes it into the client atom store. Atoms computed from server state are immediately available to client-side handlers without a round-trip.
---
## form
### req: form/001
001 Forms are source of truth in HTML. hemplate Surface exports form shape (controls, names, required, types). slhx checks compatibility with the Rust handler's `Form<T>` type. No auto-generated structs; domain types (e.g. `Email`) are first-class. The Surface describes; Rust owns; slhx checks.
### req: form/002
002 The handle id is carried as `__h` in POST `application/x-www-form-urlencoded`. A JSON body is allowed at the integration boundary (`application/json`) only if the handler accepts it; core uses form encoding.
### req: form/003
003 handler receives `form: Form<CreateTodo>`. Validation errors are returned as `Effect::form_error(field, message)`, which the JS runtime maps back to the originating input via `data-sid`.
---
## list
### req: list/001
001 Any `data-slhx-slot` or `data-slhx-handle` inside `@for` requires an explicit `key` expression. Syntax: `@for item in items key item.id { ... }`. Without `key`, slhx-addressable nodes inside the loop are rejected at build time. Keyed identity is `(SlotId, KeyValue)`.
### req: list/002
002 Slots inside a keyed loop receive a composite identity: `(SlotId, KeyValue)`, not a flat id. hemplate records `key_expr` in the Surface; slhx implements keyed slot lookups.
### req: list/003
003 Effects on keyed slots: `replace_keyed(slot, key, value)`, `remove_keyed(slot, key)`, `append_keyed(slot, key, value)`. Mismatch between key type and slot key type is compile-time error.
---
## typed_id
### req: typed_id/001
001 All public cross-page identifiers (`Slot`, `Atom`, `Handle`, `Form`) share a single internal primitive `ResourceId { kind: ResourceKind, id: u32, key: Option<ScopeKey> }`. Typed wrappers (`Slot<T>`, `KeyedSlot<K, T>`, `Atom<T>`, `Handle<I>`, `Form<T>`) enforce kind safety at compile time. No special-case opcodes per resource kind; effects address resources uniformly. [north_star]
### req: typed_id/002
002 `ResourceKind` is an internal closed enum (Slot, Atom, Handle, Form, Route). External crates may not add variants. Extensibility comes via `Effect::event`, `Effect::emit`, or custom `IntoEffect` implementations, never via new `ResourceKind` variants in core.
---
## async_data
### req: async_data/001
001 Async remote data lives in `Resource<T>` / `Query<K, T>` / `Mutation<I, O>`. These are optional, not core primitives. They provide loading/error/refresh semantics without client-side data libraries.
### req: async_data/002
002 `Effect::resource(res).reload()` triggers a re-fetch and re-render. The server sends a new EffectBatch when data is ready.
---
## scope
### req: scope/001
001 `Scope` is a first-class primitive. Keyed loops (`@for`), conditional branches (`@if`), component instances, modals, tabs, nested forms — all are scopes. A slhx-addressable node inside any dynamic scope must carry a stable `ScopeKey`. Composite identity is `(ResourceId, ScopeKey)`. [north_star]
---
## wire
### req: wire/001
001 HTML wire format is standard HTML with `data-hid` and `data-sid` attributes only. No custom markup, no hx-* attributes.
### req: wire/002
002 POST bodies carry `application/x-www-form-urlencoded` with distinguished field `__h` (handle id). Server routes by numeric id, not by URL path.
### req: wire/003
003 Responses are `text/html` fragments (or `application/slhx` for push streams). Fragments may contain `<template data-slhx>` elements whose text content is a base64url-encoded postcard `EffectBatch`. JS decodes and applies.
### req: wire/004
004 Server push is supported orthogonally: `Effect::push(stream, effect)` sends a pre-serialized effect batch over an SSE or WebSocket connection. Connection management is a server-framework concern.
### req: wire/005
005 No JSON anywhere in slhx-internal artifacts. Public request/response envelopes use `application/x-www-form-urlencoded`; effects and symbols use postcard. `application/json` is acceptable only at integration boundaries.
---
## runtime
### req: runtime/001
001 Every slhx tree must declare a root boundary via `data-slhx-root` on an ancestor element. The JS runtime resolves lookups within that root only. Multiple independent slhx apps/widgets/modals may coexist on the same document without ID collision. [north_star]
### req: runtime/002
002 JS runtime attaches a single delegated listener per event type on the root. No per-node listeners. Dispatch resolves target via `data-hid` / `data-sid` attributes on the event path.
### req: runtime/003
003 The JS runtime is a tiny op interpreter (~2KB, no selectors, no VDOM, no scheduler, no expressions). It reads postcard `EffectBatch` bytes and applies them as DOM operations.
### req: runtime/004
004 `RuntimeCaps { binary, wasm, sync, transitions }` is exchanged once at init. Handlers may query capabilities and degrade gracefully (e.g. fall back to server request if WASM unavailable). Core does not know about these features; caps are opaque to the wire protocol and only consulted by integrations.
---
## js
### req: js/001
001 Runtime reads attributes `data-hid` and `data-sid`, delegates events on `document`, and applies effects by direct DOM mutation.
### req: js/002
002 No build step, no virtual DOM, no diffing, no scheduler. Receiving an effect = apply ops immediately in declared order.
### req: js/003
003 The runtime consists of an Op interpreter (`ReplaceHtml`, `SetText`, `PatchAtom`, `Navigate`, `Focus`, `AddClass`, `RemoveClass`, `RemoveKeyed`, `CustomOp`) reading from a postcard byte stream.
---
## check
### req: check/001
001 All cross-file references verified at `cargo check`. Unknown handle → hard error. Unknown slot → hard error. Type mismatch between slot and atom → hard error.
### req: check/002
002 Dead handle warning: `#[slhx::handler]` never referenced by any template. Dead slot warning: template node never targeted by any handler.
### req: check/003
003 Renaming a slot or handle breaks `cargo check` immediately with a span pointing to the Rust handler or template source.
### req: check/004
004 Page-scoped slot lookup: JS runtime resolves `data-sid` only within the current Page root element.
---
## sync
### req: sync/001
001 `slhx-sync` is an optional crate for collaborative / multiplayer state. Provides presence tracking, patch reconciliation, conflict resolution (server-authoritative), and offline queueing. Not part of core.
### req: sync/002
002 `SyncEffect::send_patch(atom, patch)` queues a state diff for server sync. If offline, the patch is stored in a local queue and sent when connection resumes. If online, it is sent immediately via WebSocket/SSE.
### req: sync/003
003 `Effect::ack(atom)` acknowledges a successful server-side mutation, allowing the client to clear its local optimistic queue for that atom.
### req: sync/004
004 `Effect::broadcast(channel, effect_batch)` sends an `EffectBatch` to all subscribers of a named channel. Used for presence updates and live collaboration. The server framework manages the transport (WS/SSE).
### req: sync/005
005 `#[slhx_sync::presence]` is an attribute macro on functions that return `impl IntoEffect` when a user joins or leaves a shared session. Emits `Effect::broadcast` over a presence channel scoped to the session.
### req: sync/006
006 `slhx-sync` uses a flat patch model per atom, not CRDT by default. Server is authoritative; clients apply server-canonical state on conflict. Optional CRDT backend may be provided by a future `slhx-crdt` crate.
---
## interop
### req: interop/001
001 `Effect::event` (see req:effect/006) is the single bridge between slhx and third-party JS. External widgets, charts, and maps listen via native `CustomEvent`. slhx core does not inspect or manage them. [north_star]
### req: interop/002
002 Web Components and custom elements are valid opaque leaf nodes. slhx does not inspect shadow DOM. Escape hatches are leaves, never app foundations.
### req: interop/003
003 WASM islands (`#[slhx::island]`) compile handler code to WASM for client-local execution. The island is a leaf in the DOM; slhx core is unaware of WASM except via the same `EffectBatch` contract.
---
## test
### req: test/001
001 `EffectWriter` implements a test backend so handlers can be unit-tested without a browser: `slhx_test::run(handler, input)` returns an `EffectInspector` with `contains(op)`, `has_slot(slot)`, `has_atom(atom)`, etc.
---
## navigation
### req: nav/001
001 Navigation is an effect, not a router framework: `Effect::navigate(url, mode, scroll, title)`. Core supports `Push`, `Replace`, `Redirect`. Actual route matching, guards, loaders, and nested routes are outside core.
### req: nav/002
002 Navigation modes: `Push` (history.pushState), `Replace` (replaceState), `Redirect` (server-side 302). Scroll behaviour: `Preserve`, `Top`, `Element(ResourceId)`. Title is optional.
---
## escape_hatch
### req: escape_hatch/001
001 Three sanctioned escape hatches exist: (1) Web Components as opaque leaf nodes, (2) `Effect::event` for imperative JS interop, (3) WASM islands for CPU-intensive client logic. All three are leaves in the slhx tree, never the app foundation.
---
## derive_handler
### req: derive_handler/001
001 `#[slhx::handler]` validates: handle name exists in symbol table, params match form surface or `data-*` attributes, return type implements `IntoEffect`. Generate code registers the function in a static lookup table keyed by numeric handle id.
### req: derive_handler/002
002 Handler param inference from `data-*` attributes: when a template declares `data-card-id="{card.id}"` on a node with `data-slhx-handle`, the handler may declare `card_id: CardId` as a parameter. slhx-build checks attribute → param name and type mapping.
---
## derive_app
### req: derive_app/001
001 `#[slhx::app]` marks the root application struct containing all global atoms. It is the registry entry point for `slhx_build`. Zero or single instance per process.
---
## ts
### req: ts/001
001 TypeScript definitions for `slhx-js` runtime are shipped as a single `.d.ts` file. Types mirror the postcard `EffectBatch` schema for advanced consumers. Tooling must not depend on these types for core functionality; they are developer convenience only.
---
## milestone
### req: ms/001
001 **Milestone app: 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 acts as the north-star integration test for slhx + hemplate + slhx-sync. [north_star]
---
## misc
### req: misc/001
001 Workspace layout: `slhx-core` (types + postcard schema, no_std), `slhx-derive` (proc-macros), `slhx-build` (surface consumer + code generation), `slhx-axum` (integration), `slhx-js` (runtime single file), `slhx-transition` (optional), `slhx-sync` (optional), `slhx-wasm` (optional). No kitchen-sink crate.
### req: misc/002
002 All crates compile on stable Rust. MSRV 1.80. `slhx-core` has zero proc-macro dependencies.
### req: misc/003
003 No auth, no routing, no session storage inside slhx core. `slhx-axum` provides typed route mounting; actual routing is axum/tower.
### req: misc/004
004 Three execution modes supported: server-first (request/response), client-local WASM (requestAnimationFrame, no round-trip), and hybrid sync (local + remote via `slhx-sync`). Modes are opt-in per handler, not global.
### req: misc/005
005 The only user-facing proc-macro is `#[slhx::handler]`. No `!` call-syntax macros. Attribute macros only.
### req: misc/006
006 Source spans are present on every Surface node, attribute, and scope. Error messages cite file, line, and column. This is non-negotiable for DX.
### req: misc/007
007 All slhx HTML attributes use `data-` prefix (`data-slhx-handle`, `data-slhx-slot`, `data-slhx-atom`, `data-slhx-root`). No unprefixed custom attributes. Valid HTML, tool-friendly, zero custom syntax.
### req: misc/008
008 Id allocation is deterministic from canonical symbol paths. Ids are stable across builds unless the symbol path changes. Deploy mismatch between server and client is caught by a build-schema version check, not silent failure.
### req: misc/009
009 Core design rule: add one primitive only if it deletes five special cases. `ScopeKey` deletes: loop keying, component scoping, modal instances, nested forms, portal boundaries. `ResourceId` deletes: special opcodes per kind, separate registries, separate wire formats, separate test APIs. `Effect::event` deletes: plugin API, custom JS bridges, chart adapters, map SDK wrappers.
+352
View File
@@ -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.
```