18 KiB
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.