Files
hemx/REQUIREMENTS.md
T
slhx agent 4e47b470a7 feat(axum): add safe html page composition
Add a SafeHtml PageRequest path for shell/partial composition and migrate examples so rendered hemplate fragments stay typed through the transport boundary instead of round-tripping through unchecked strings.

req: axum_integration/001

req: html_safety/001

req: html_safety/002
2026-06-01 23:45:52 +02:00

51 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.


laws

req: law/001

001 A feature belongs in core only if it can be expressed as typed resources plus EffectBatch ops.

req: law/002

002 A feature belongs in an integration crate if it depends on transport, framework, auth, storage, browser capability, or deployment policy.

req: law/003

003 A feature belongs in generated API if it improves author ergonomics without adding runtime semantics.

req: law/004

004 A feature belongs in user code if it is business logic, domain validation, routing policy, authorization policy, persistence, or layout choice.

req: law/005

005 Add one primitive only if it removes at least five special cases.


pitch

req: pitch/001

001 slhx is checked hypermedia for Rust. Write .heml, write #[slhx::handler], return commands. The compiler checks every cross-file reference. The browser runtime only sees ids and effect bytes. No JS app code required. [north_star]

req: pitch/002

002 No CSS selectors. No hx-* strings. No virtual DOM. No client framework. No hidden global proxy magic. No component hydration. SSR state bootstrap via data-slhx-st is allowed, but the browser never reconstructs a component tree. [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: pitch/004

004 The north-star feel: Svelte at the call site, Rust at the boundary. Short handler bodies, compile-checked HTML contracts, one language for server and client logic. [north_star]


modes

req: mode/001

001 slhx has two happy paths: Page Enhancer and Interaction Handler.

req: mode/002

002 Page Enhancer mode replaces minimal HTMX page swapping. Authors use real anchors with data-slhx-nav or data-slhx-boost; no user-authored handler is required.

req: mode/003

003 Interaction Handler mode handles forms, buttons, typed params, and targeted updates through #[slhx::handler].

req: mode/004

004 Beginner docs must teach Page Enhancer first, Interaction Handler second, Atoms third, client-local/WASM fourth, sync last.


dx

req: dx/001

001 The common case must feel like writing a Svelte/Vue component: template, state, handlers, and targeted updates. Users should not need to understand Surface IR, ResourceId, EffectWriter, postcard, or runtime opcodes for basic apps. [north_star]

req: dx/002

002 The happy path is: write .heml, write #[slhx::handler], return generated slot/atom commands. No manual ids, no manual registry, no manual serialization, no manual JavaScript. [north_star]

req: dx/003

003 Public APIs are generated around the user's names. If the template declares data-slhx-slot="todo_list", the user gets slots::todo_list, not SlotId(12).

req: dx/004

004 Common handlers must fit in a small function. Advanced contexts (EffectWriter, raw ops, custom encoders) exist but are not part of the beginner path.

req: dx/005

005 Error messages must explain fixes in author language, not internal language. Say “add h-key="todo.id" to this h-for”, not “missing ScopeKey for ResourceRef”.

req: dx/006

006 Generated resource methods are the preferred authoring API: slots::todo_list.render(view), slots::card.replace(key, view), slots::count.text(42), atoms::user.set(user). The public facade and generated view modules expose render(view) for trusted hemplate-to-SafeHtml page and fragment composition, plus lower(html) for prototype/static .heml fragments that need generated resource lowering; render_html(view) and lower_html(html) remain explicit compatibility aliases. These return impl IntoEffect, SafeHtml, or lowered HTML at the boundary. Raw Effect constructors, opcodes, and EffectWriter remain low-level. [north_star]

req: dx/007

007 Tuple composition of IntoEffect is the canonical batch syntax: (a, b, c) implements IntoEffect up to arity 12. Effect::batch((...)) is available but not required for the happy path.

req: dx/008

008 User-authored JavaScript is never required for standard forms, lists, navigation, optimistic actions, or server push. Custom JS is only needed at opaque leaf boundaries such as charts, maps, editors, and Web Components.


ceremony

req: ceremony/001

001 A minimal counter app requires one .heml file, one Rust state struct, and one handler function. No manual registry, no manual route table, no manual JS. Under 50 lines of user-authored Rust plus one template.

req: ceremony/002

002 Generated modules are imported through a prelude or component namespace. Users should not manually include $OUT_DIR files in normal apps.

req: ceremony/003

003 build.rs must be a one-liner for the common case: fn main() { slhx_build::app().run().unwrap(); }

req: ceremony/004

004 No API may require users to write numeric ids, raw ResourceIds, raw opcodes, or serialized payloads in normal code.


progressive_disclosure

req: pd/001

001 A beginner can build CRUD with only: .heml, #[slhx::handler], Form<T>, generated slots::* methods, and impl IntoEffect.

req: pd/002

002 Atoms are not required for basic server-first apps. They appear only when client-local state, SSR bootstrapped state, or WASM handlers are used.

req: pd/003

003 Sync, transitions, resources/queries, islands, capabilities, and raw EffectWriter are advanced layers. They must not appear in starter examples.

req: pd/004

004 Documentation must present three levels: server-first, client-local, hybrid-sync. Each level introduces only the new primitive it needs.


page_swap

req: page_swap/001

001 Minimal page swapping is a first-class slhx-axum happy path. Authors mark real anchors with data-slhx-nav; links keep valid href and work without JS. Missing or empty static href on a data-slhx-nav anchor is a build error.

req: page_swap/002

002 A data-slhx-nav click fetches the target URL as a slhx partial request. The response updates the canonical content slot, optionally navigation and title, then applies a Navigate effect.

req: page_swap/003

003 Page swapping uses generated slots, not CSS selectors. The default content target is the slot named content, not #content.

req: page_swap/004

004 Minimal page swap must not require user-authored #[slhx::handler]. Explicit navigation handlers are available only when custom application logic is needed.

req: page_swap/005

005 Browser back/forward is supported. On popstate, slhx fetches the URL as a partial request and applies the same page-swap update without pushing a new history entry.

req: page_swap/006

006 If a page lacks the expected content slot, slhx-axum falls back to normal browser navigation in production and emits a diagnostic in development.

req: page_swap/007

007 data-slhx-boost progressively enhances descendant same-origin anchors and forms. It is a container convention, not a replacement for data-slhx-nav on an anchor or data-slhx-handle on a form; placing it directly on static anchors or forms is a build error. Links behave like data-slhx-nav; forms behave like slhx form submissions. External links, downloads, new-tab links, and modified-clicks preserve native browser behavior.


htmx_equivalents

req: htmx/001

001 slhx replaces common HTMX use-cases through typed equivalents, not HTMX syntax.

req: htmx/002

002 Easy equivalents must exist for: boosted links/forms, page swap, form submit, targeted replacement, append/prepend/remove, loading indicators, confirmation, debounce/throttle, polling, history navigation, multi-target updates, response events, SSE/push, and validation errors.

req: htmx/003

003 slhx core deliberately does not clone selector-based HTMX features: hx-target selectors, hx-select, hx-include selectors, closest/find/this target strings, or trigger mini-languages. Equivalent patterns use generated slots, typed params, forms, and explicit handlers.


component

req: component/001

001 The primary authoring unit is a hemplate component plus adjacent Rust handlers. A component owns a template root, generated slots, generated handles, generated form checks, and source spans.

req: component/002

002 slhx supports colocated layout: todo_list.heml beside todo_list.rs, with generated APIs namespaced by component to avoid global symbol soup.

req: component/003

003 Generated APIs are component-namespaced by default: ui::todo_list::slots::todo_row, ui::todo_list::handles::create, ui::todo_list::forms::create, and ui::todo_list::COMPONENT as a checked ComponentRef. Global exports (ui::slots::*, ui::handles::*, ui::components::*) are opt-in only.

req: component/004

004 #[slhx::surface] bridges generated code into a user module. Users write #[slhx::surface] mod ui {} instead of include!(concat!(env!("OUT_DIR"), ...)). slhx-build emits slhx.generated.rs which the macro expands in place. No direct $OUT_DIR includes in user-authored source.

req: component/005

005 An optional #[slhx::component] macro may validate that every handle declared in the template Surface has a corresponding #[slhx::handler] within the annotated module. This is the only macro with cross-handler visibility inside a single module; it remains strictly local. Missing handlers without #[slhx::component] are caught at app mount or test time, not cargo check.

req: component/006

006 #[derive(Hemplate)] structs are natural component boundaries. slhx_build discovers them automatically; no additional configuration is required for most apps.


surface

req: surface/001

001 hemplate_build may scan .heml files and emit $OUT_DIR/hemplate.surface.postcard (postcard-encoded, deterministic, versioned). slhx_build may also be called in-process with precomputed hemplate Surface facts or, for simple build scripts, ask hemplate to parse/extract the Surface before slhx interprets it. slhx owns no independent .heml parser.

req: surface/002

002 The Surface contains: nodes (NodeId, parent, scope, element, attrs, source span), scopes (ScopeKind: Root | If | Match | Case | 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. slhx_build enforces key presence only when a slhx-addressable node appears inside the loop.

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: surface/008

008 The Surface records hemplate structural directives as first-class facts: h-for, h-key, h-if, h-else-if, h-else, h-match, h-case, dynamic +attr bindings, and interpolated attr/text expressions. slhx consumes these facts; if a build script points slhx_build at .heml files, hemplate still performs parsing and Surface extraction.

req: surface/009

009 Raw/pre-rendered HTML insertions are opaque Surface holes. The parent element is present; slhx_build emits the appropriate rendering call.

req: surface/010

010 Attribute values preserve their origin: static literal, dynamic +attr binding, or interpolated expression. slhx-build uses this to determine whether a data-* handle param is statically known or runtime-extracted.


codegen

req: codegen/001

001 slhx_build generates three artifacts from the generic Surface IR: (a) slhx.generated.rs containing ergonomic resource modules (slots, handles, forms, atoms), (b) slhx.syms for proc-macro validation, (c) runtime id-lowering tables. slhx_build interprets tool-specific conventions (data-slhx-*, h-for, h-key, form controls) from the Surface. [north_star]

req: codegen/002

002 Generated module slots exposes ergonomic methods: Slot<T>::render(value), Slot<T>::text(value), KeyedSlot<K,T>::append(key, value), KeyedSlot<K,T>::prepend(key, value), KeyedSlot<K,T>::replace(key, value), KeyedSlot<K,T>::remove(key). Methods return impl IntoEffect.

req: codegen/003

003 Generated module handles exports typed constants: Handle<I> where I is Form<T>, a param type, or (). Users rarely reference handles directly; they are consumed by #[slhx::handler] for validation.

req: codegen/004

004 Generated module forms exports FormContract metadata (field names, HTML control kinds, required). #[slhx::handler] compares the Form<T> type against the contract. Domain types (Email, TodoId) remain user-authored; no auto-generated structs.

req: codegen/005

005 Generated module atoms exports Atom<T> for values that must be addressable, bootstrapped, or synced. Ordinary Rust fields on app/components are not automatically atoms.

req: codegen/006

006 slhx-build discovers data-slhx-on event names from hemplate Surface inputs and emits generated slhx::EventName constants. Event constants are metadata for checked Rust authoring and diagnostics; they do not create a trigger mini-language or new browser runtime semantics. [north_star]


public_api

req: public_api/001

001 The generated API is the primary public authoring API. Most user code should return generated slot/atom/form/nav commands, not raw Effect constructors.

req: public_api/002

002 Effect, EffectWriter, ResourceId, ResourceRef, and raw opcodes are advanced APIs. They must not appear in beginner docs, generated examples, or common diagnostics.

req: public_api/003

003 Every generated command returns impl IntoEffect and composes through tuple composition.

req: public_api/004

004 If a common UI operation requires raw EffectWriter, the public API is considered incomplete.


effect_algebra

req: effect_algebra/001

001 The canonical op set is minimal and closed: Put, Insert, Remove, Move, Focus, Navigate, Emit.

req: effect_algebra/002

002 Put replaces the payload of a resource. For a Slot, this means replacing its rendered contents. For an Atom, this means replacing its stored value.

req: effect_algebra/003

003 Insert, Remove, and Move operate on keyed collection resources. They require a key type checked by generated KeyedSlot<K, T> wrappers.

req: effect_algebra/004

004 Navigate changes browser history or represents a server redirect. Route matching remains outside slhx core.

req: effect_algebra/005

005 Emit dispatches a native CustomEvent and is the only raw JS interop primitive in core.

req: effect_algebra/006

006 DOM-specific operations such as innerHTML, textContent, class toggles, or keyed node lookup are runtime lowering details, not separate author-facing concepts.


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 }. A concrete runtime target is a ResourceRef { resource: ResourceId, scope: 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). Navigation is represented by Navigate effects, not by route resources. External crates may not add variants. Extensibility comes via Effect::event or custom IntoEffect implementations, never via new ResourceKind variants in core. Effect::event lowers to the canonical Emit opcode.


scope

req: scope/001

001 Scope is a first-class primitive. Keyed loops (h-for) create keyed dynamic scopes and require h-key for slhx-addressable nodes. Conditional branches (h-if, h-else-if, h-else, h-match, h-case) create optional presence scopes. Component instances, modals, tabs, and nested forms are scoped resources. Concrete runtime targets are addressed through ResourceRef { resource: ResourceId, scope: Option<ScopeKey> }. [north_star]


list

req: list/001

001 Any data-slhx-slot or data-slhx-handle inside a hemplate h-for scope requires a stable key. Preferred syntax: <template h-for="item in &self.items" h-key="item.id"> ... </template>. Without a key, slhx-addressable nodes inside the loop are rejected at build time. Keyed identity is ResourceRef { resource: ResourceId, scope: Some(ScopeKey::KeyValue(...)) }.

req: list/002

002 Slots inside a keyed loop receive a composite identity. 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), prepend_keyed(slot, key, value). Mismatch between key type and slot key type is compile-time error.


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 Rust Form<T> types through user-authored #[slhx::form("...")] domain structs and generated form metadata. 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 target (FormId, field_name) or generated control ids. The runtime maps them to originating form controls via control ids derived from Surface NodeId, not via slot ids.

req: form/004

004 Form compatibility checks validate field presence, optionality, multiplicity, and parser availability. Parser availability means the submitted value type implements slhx::FormValue (blanket-provided for FromStr, or explicitly implemented for custom parsers). Domain validation remains Rust logic (TryFrom, custom validators, or handler code).

req: form/005

005 HTML control facts are lower bounds, not complete domain semantics. type="email" may require a Rust Email parser, but hemplate never defines what a valid business email is.

req: form/006

006 Generated diagnostics distinguish structure errors from validation errors: missing field / wrong optionality are compile-time issues; invalid submitted values are runtime form errors.


form_effects

req: form_effects/001

001 Generated form APIs provide common commands: reset(), clear(field), error(field, message), focus(field), and disable_while_pending().

req: form_effects/002

002 Form effects target generated form/control ids, not CSS selectors.

req: form_effects/003

003 Templates may declare error display targets with data-slhx-error-for="field". Generated form error effects render into those targets when present and fall back to control validity APIs otherwise.


wire

req: wire/001

001 Authoring HTML uses symbolic data-slhx-* attributes. Rendered runtime HTML lowers these to compact numeric metadata: data-hid, data-sid, optional data-key, optional atom ids, optional form/control ids, and data-slhx-st for state bootstrap. The browser never sees handler or slot names. data-slhx-root marks a scoped root boundary.

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 HTTP interaction responses may be text/html fragments containing <template data-slhx>.... Push streams use application/slhx or transport-specific event frames carrying serialized EffectBatch.

req: wire/004

004 Server push is orthogonal: integration crates stream postcard EffectBatch over SSE or WebSocket connections. slhx core owns the effect bytes; transport and connection management are integration concerns.

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.


abi

req: abi/001

001 Surface IR, slhx symbols, generated Rust API, EffectBatch wire schema, and JS runtime each carry explicit schema/ABI versions.

req: abi/002

002 slhx_build emits a build fingerprint derived from Surface schema version, resource id allocation, EffectBatch ABI version, and runtime ABI version.

req: abi/003

003 The server includes the slhx build fingerprint in initial roots. The runtime compares it with its own fingerprint before applying EffectBatches.

req: abi/004

004 On fingerprint mismatch, the runtime refuses partial updates and falls back to full page navigation or reload. Silent mismatch is forbidden.

req: abi/005

005 Resource ids are deterministic within a build and stable across builds when canonical symbol paths do not change. Stability is best-effort across refactors, not a persistence guarantee.


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 each data-slhx-root. No per-node listeners. Dispatch resolves target via data-hid / data-sid attributes on the event path scoped to its root.

req: runtime/003

003 The core JS runtime target is under 5KB minified+gzipped. It remains a tiny op interpreter (no selectors, no VDOM, no scheduler, no expressions). It reads postcard EffectBatch bytes and applies them as DOM operations. Optional sync/transition/WASM helpers are separate files.

req: runtime/004

004 Core runtime exposes a minimal version/fingerprint handshake only. Capability negotiation belongs to integration crates such as slhx-wasm, slhx-sync, and slhx-transition.


failure

req: failure/001

001 Missing runtime targets are non-panicking. In development, the runtime emits a diagnostic event and logs the missing ResourceRef. In production, missing optional targets no-op; missing required targets fail the batch and report a recoverable error.

req: failure/002

002 EffectBatch application is ordered and transactional per root where possible. If an op fails, later ops in the same batch are skipped unless the op is marked best-effort.

req: failure/003

003 Form parse errors do not call the handler. They produce typed form errors targeting generated control ids.

req: failure/004

004 Handler errors may map to HTTP responses, form errors, navigation effects, toast/events, or app-defined error effects. Core does not prescribe UI policy.

req: failure/005

005 Wire/schema version mismatch is a hard failure. The runtime refuses to apply unknown incompatible EffectBatch versions and falls back to full page reload when possible.

req: failure/006

006 Progressive enhancement failures preserve native browser behavior for forms and links whenever valid HTML fallback exists.


axum_integration

req: axum/001

001 slhx-axum supports the common shell/partial pattern. Full-page requests are wrapped in a user-provided Shell; slhx/partial requests may return only the rendered component or an EffectBatch. The shell/partial helper has a SafeHtml path so already-rendered hemplate fragments can cross the page boundary without downgrading to unchecked strings.

req: axum/002

002 Existing Axum routes remain normal Axum routes. slhx does not own routing. slhx-axum only mounts handler dispatch, runtime assets, and optional push endpoints.

req: axum/003

003 Interactive fragments that would traditionally be implemented as /demo/... HTMX endpoints should be expressible as #[slhx::handler] functions returning generated slot commands.

req: axum/004

004 Query-string demo endpoints may be migrated to typed handler params from data-* attributes or forms. Query<T> remains available in normal Axum routes but is not the slhx happy path.


auth

req: auth/001

001 Auth is not part of slhx core. Authentication, authorization, sessions, cookies, CSRF, and permissions are handled by axum/tower extractors and middleware. slhx handlers may accept typed auth/context extractors.

req: auth/002

002 slhx-axum preserves normal HTTP auth semantics. Unauthorized handlers may return normal HTTP 401/403, a navigation effect, or an application-defined auth failure effect.

req: auth/003

003 Progressive enhancement is preserved: login/logout forms remain valid HTML forms. With JS disabled, the server performs normal redirects; with slhx enabled, handlers may return EffectBatch responses.

req: auth/004

004 CSRF is integration-level. slhx-axum must allow normal hidden form fields, cookies, and extractor-based CSRF validation. slhx core does not define CSRF policy.

req: auth/005

005 slhx requests preserve standard HTTP credentials semantics. Cookies, SameSite policy, Authorization headers, and session middleware remain framework/browser concerns.


push

req: push/001

001 Server push streams canonical postcard EffectBatch over SSE or WebSocket. slhx core owns the EffectBatch schema, not the transport.

req: push/002

002 SSE/WebSocket connections are authenticated by the server framework before stream creation. slhx does not define auth semantics for streams.

req: push/003

003 HTMX-style SSE swaps are represented as streamed effects targeting generated slots/atoms. No selector-based sse-swap semantics in core.

req: push/004

004 Out-of-band updates are ordinary multi-target EffectBatches.

req: push/005

005 Push is one-way server-to-client delivery of EffectBatch. It does not define client mutation, optimistic queues, reconciliation, or conflict handling.

req: push/006

006 data-slhx-sse is declared on data-slhx-root and opens only non-empty same-origin SSE URLs by default. Empty static URLs and non-root placement are build errors; cross-origin streams belong to explicit integration code rather than the standard runtime convention.


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 Server reconciliation of received patches produces a local EffectBatch only when state changes. Accepted mutations patch shared state without hard-coding specific effects.

req: sync/004

004 SyncEffect::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 SyncEffect::broadcast over a presence channel scoped to the session.

req: sync/006

006 SyncEffect::ack(atom) acknowledges a successful server-side mutation, allowing the client to clear its local optimistic queue for that atom.

req: sync/007

007 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: sync/008

008 Sync is bidirectional state reconciliation built on top of push/transport. It is not required for server-sent dashboards, notifications, or live status updates.


interop

req: interop/001

001 Effect::event 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.

req: interop/004

004 Existing hx-* attributes are treated as ordinary raw attributes in the hemplate Surface without slhx semantics. An optional slhx-htmx-migrate tool may read Surface hx-* attrs and suggest equivalent data-slhx-* handlers/effects.

req: interop/005

005 HTMX-style response triggers are represented by Effect::event or generated event helpers. Events are native CustomEvents scoped to the slhx root.


nav

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(ResourceRef). Title is optional.

req: nav/003

003 Navigation enhancement preserves real anchors. Links keep valid href. slhx may intercept enhanced links through data-slhx-handle or data-slhx-nav, but without JS the browser performs normal navigation.

req: nav/004

004 slhx supports both normal HTTP redirects and navigation effects. HTTP redirects are preferred for full-page/non-enhanced flows; navigation effects are preferred for enhanced interaction responses.

req: nav/005

005 Page swap preserves browser history semantics: push, replace, popstate, scroll behavior, and normal modified-click behavior. Back/forward may re-fetch partial content or restore from a bounded cache; correctness must not depend on the cache.


html_safety

req: html/001

001 Raw HTML insertion requires an explicit safe HTML type (SafeHtml or equivalent). Plain String renders as escaped text unless explicitly wrapped.

req: html/002

002 Hemplate-rendered output may be converted to SafeHtml by trusted render APIs. User input is never SafeHtml by default. Full-page shell composition may pass already-rendered hemplate fragments through explicit SafeHtml fields; handlers should prefer slot/resource render helpers for effect payloads.

req: html/003

003 Slot render commands distinguish text payloads from HTML payloads at the type level.


view

req: view/001

001 Slots render view types, not necessarily domain types. Domain-to-view conversion is explicit Rust (From, Into, or constructor). slhx never assumes a domain object is its own view.

req: view/002

002 Generated slot types are allowed to target Display, Hemplate, or explicit view wrappers. Type errors should suggest the expected renderable view type.


diagnostics

req: diag/001

001 Every compile-time error must point to both sides of the mismatch when possible: the Rust handler span and the template Surface span.

req: diag/002

002 Diagnostics must include a suggested fix for common cases: missing key, unknown slot, missing form field, optionality mismatch, handler param mismatch, wrong keyed slot type.

req: diag/003

003 Internal terms (ResourceId, ScopeKey, EffectBatch) must not appear in beginner-facing diagnostics unless --verbose is enabled.


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.

req: test/002

002 #[slhx::component] may compile without generated Surface files for incremental module-local testing. #[slhx::surface] requires slhx.generated.rs in $OUT_DIR and fails with an actionable diagnostic when generation is missing, because it is the public generated API bridge.

req: test/003

003 Generated registries are validated by compile-time tests: missing handler implementations produce test failures with actionable messages.

req: test/004

004 Repository-wide verification uses a resource-aware runner that caps Cargo build jobs and Rust test threads from available CPU and memory. User-requested concurrency cannot exceed the detected safe cap. Browser E2E runs as an isolated step and can be skipped explicitly when browser infrastructure is unavailable.

req: test/005

005 Tests that inspect rendered HTML structure, attributes, escaping, or ordering use DOM-aware parsing such as scraper or existing local HTML parsing helpers. Raw string assertions are reserved for tiny literal payload checks where parsing would add noise. [north_star]


check

req: check/001

001 All symbolic cross-file references visible to build/proc-macro validation are 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/missing handle diagnostics are best-effort by default. With #[slhx::component], dead/missing handlers inside the component are checked at cargo check. Without it, missing implementations are caught at app mount or in generated registry tests.

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 Root-scoped slot lookup: JS runtime resolves data-sid only within the nearest/current data-slhx-root.


state

req: state/001

001 Typed atoms with Atom<T> are explicit addressable state resources.

req: state/002

002 Atoms are not reactive by default. Updating an atom does not re-render anything until a handler returns an effect referencing it.

req: state/003

003 The JS runtime maintains a client-side atom store keyed by AtomId. Runtime values are type-erased postcard bytes. Types are compile-time only. A deterministic TypeHash may be generated by slhx_build for diagnostics, but the JS runtime does not depend on Rust TypeId.

req: state/004

004 SSR roots may carry a data-slhx-st base64url-encoded postcard blob on the data-slhx-root element. Runtime decodes it into the client atom store. Atoms computed from server state are immediately available to client-side handlers without a round-trip.

req: state/006

006 Malformed data-slhx-st bootstrap state must not stop the standard runtime from binding roots, handlers, navigation, or push. The runtime reports slhx:state-error and continues with an empty atom store for that root.

req: state/005

005 Not all state is an Atom. Ordinary Rust fields are preferred unless the value must be independently addressed, bootstrapped, synced, or subscribed. Atoms are explicit resources, not the default state container.


client_local

req: client_local/001

001 Client-local handlers use the same function shape as server handlers. Opting into a client-local backend changes where the handler executes, not the authoring model.

req: client_local/002

002 Local UI state may live as ordinary fields on the app/component state. Atom<T> is required only when the value must be addressed by effects, bootstrapped, synced, or subscribed.

req: client_local/003

003 High-frequency UI handlers (drag, pointermove, animation tick) must not require server round-trips or handwritten JS.

req: client_local/004

004 The exact opt-in syntax for client-local handlers is not part of slhx-core v0. #[slhx::handler(client)] is illustrative; final syntax belongs to slhx-wasm integration.


async_data

req: async_data/001

001 If introduced, async remote data helpers live in optional Resource<T> / Query<K, T> / Mutation<I, O> integrations, not in v0 core. v0 server-first data loading is ordinary Rust/Axum code.

req: async_data/002

002 QueryEffect::reload(res) triggers a re-fetch and re-render. The server sends a new EffectBatch when data is ready. Query/Resource effects live in a separate API surface to keep core small.

req: async_data/003

003 If introduced, query helpers must compile to ordinary handlers and effects; they must not introduce a client-side data framework or cache as a core dependency.


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 that are visible to build/proc-macro validation fail at cargo check with a precise span. Global completeness checks, such as missing handler implementations across a crate, are cargo check errors only inside #[slhx::component]; otherwise they are caught at app mount or generated registry tests.

req: invariant/005

005 slhx core owns effects, typed ids, and registries only. Routing, auth, sessions, transport, transitions, and sync are integration concerns.


v0_scope

req: v0/001

001 v0 stable release includes: Surface consumption, generated slots/forms/handles, #[slhx::handler], tuple IntoEffect, form dispatch, typed data-* params, keyed slots, page swap, root-scoped runtime, EffectBatch wire schema, diagnostics, tests, and slhx-axum integration.

req: v0/002

002 v0 excludes: sync, wasm/client-local handlers, transitions, query/cache helpers, CRDT, custom component lifecycle, built-in auth, built-in router, and HTMX compatibility mode.

req: v0/003

003 v0 proof apps are: counter, docs-site HTMX replacement, form wizard, auth action, SSE notification stream, and keyed todo list.

req: v0/004

004 The local-first kanban remains the north-star milestone, not a v0 blocker.


examples

req: examples/001

001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through Effect::event, without moving island mechanics into slhx core.

req: examples/002

002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions.

req: examples/003

003 If an example requires raw EffectWriter, manual ids, manual JS, or manual registry setup, the API is considered too complex.

req: examples/004

004 Canonical examples are compile-tested golden API contracts. Changing generated API shape requires updating the examples deliberately.

req: examples/005

005 Canonical examples must not contain user-authored browser JavaScript. They may load the shared slhx runtime (/slhx.js) and may use declarative data-slhx-* attributes; inline <script>, on*= event handlers, and javascript: URLs are forbidden outside opaque leaf-widget examples.


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]


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.


build

req: build/001

001 Build order: .heml → hemplate Surface facts (precomputed by hemplate_build or extracted in-process by hemplate for slhx_build) → slhx_buildslhx.generated.rs + slhx.syms + diagnostics.

req: build/002

002 Proc-macros (#[slhx::handler], #[slhx::surface]) are side-effect free. They read generated artifacts (slhx.syms, slhx.generated.rs) but never parse .heml, never process generic Surface IR, and never write files. Global codegen lives only in build.rs invoked by slhx_build. [north_star]

req: build/003

003 slhx-derive (#[slhx::handler]) reads slhx.syms at expansion time to validate handle names, slot names, and form signatures. It generates only local glue (static fn-table entry) plus compile errors.

req: build/004

004 #[slhx::surface] reads slhx.generated.rs from $OUT_DIR and expands it into the annotated module. It is a pure include/bridge macro with no semantic analysis of its own.

req: build/005

005 A build.rs failure (missing Surface, version mismatch, stale hash) is a hard error before proc-macro expansion.

req: build/006

006 Proc-macros are considered local: they have knowledge of the item they annotate, plus pre-generated symbol tables. They do not have global knowledge of all handlers across the crate. Global checks (e.g. every declared handle has an implementation) are either deferred to app-mount tests or enabled by an optional #[slhx::component] macro.


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 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/004

004 The only required user-facing proc-macro in slhx core is #[slhx::handler]. Optional ergonomic macros may exist: #[slhx::surface], #[slhx::component], #[slhx::app], and integration-crate macros such as #[slhx::island] or #[slhx_sync::presence]. No ! call-syntax macros.

req: misc/005

005 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/006

006 Authoring slhx attributes use the data-slhx-* prefix. Runtime lowering may emit compact data-hid, data-sid, data-key, atom ids, and control ids. No unprefixed custom attributes.

req: misc/007

007 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/008

008 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.


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-* or any other tool-prefixed attributes. It records them faithfully as generic raw attributes in the Surface.

req: boundary/003

003 slhx never owns .heml parsing semantics directly. It consumes hemplate Surface facts, either from hemplate.surface.postcard emitted by hemplate_build or from in-process hemplate Surface extraction requested by slhx_build. slhx interprets tool-specific conventions (data-slhx-handle, data-slhx-slot, etc.) from the generic Surface.


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.

req: derive_handler/003

003 Handler parameters are inferred from four sources: Form<T>, data-* attributes on the triggering node, route params supplied by integration crates, and explicit app/context parameters. Missing or incompatible params are compile-time errors with source spans. slhx does not implement selector-based hx-include; shared params are represented by forms, hidden inputs, scoped context, or explicit data-* attributes.

req: derive_handler/004

004 The common handler signature forms are:

fn my_handler() -> impl IntoEffect
fn my_handler(form: Form<CreateTodo>) -> impl IntoEffect
fn my_handler(app: &mut AppState) -> impl IntoEffect
fn my_handler(card_id: CardId, app: &mut AppState) -> impl IntoEffect

All forms support returning impl IntoEffect and compose through tuples.


derive_app

req: derive_app/001

001 #[slhx::app] marks an application/root state type and registry entry point. There may be one registry per app/root type, and multiple runtime instances may exist per process. slhx does not require a process-global singleton.


locality

req: locality/001

001 slhx does not support selector targets such as closest, find, or this in core. The equivalent pattern is a named/generated slot on the local component or keyed row.

req: locality/002

002 Diagnostics should suggest adding data-slhx-slot to the local element when users attempt a self/row update pattern.


style

req: style/001

001 Plain CSS and SCSS own appearance. slhx-build discovers static class tokens from .heml, .css, and .scss build inputs and generates CssClass constants so Rust can reference known classes without raw strings. slhx does not parse selectors for behavior, cascade policy, or layout semantics. [north_star]

req: style/002

002 Generated class constants are ergonomic references only: they do not create a CSS framework, require a framework project structure, or make dynamic class expressions compile-time facts. Unknown Rust class references fail by normal Rust name resolution when the generated constant is absent. [north_star]

req: style/003

003 When a hemplate dynamic class attribute needs more than one class token, Rust passes a displayable list of generated CssClass values as view data. Rust should not assemble ad hoc class strings for known style tokens. [north_star]


convention

req: convention/001

001 slhx-axum and the JS runtime support common UX conventions as attributes, not core effects: data-slhx-pending-class, data-slhx-indicator, data-slhx-confirm, data-slhx-debounce, data-slhx-throttle, data-slhx-every, data-slhx-disable-while-pending, data-slhx-policy, and data-slhx-on. These are orthogonal to the core effect algebra.

req: convention/002

002 Default event triggers: submit for forms, click for buttons and links. data-slhx-on overrides the default for the runtime-supported delegated events: click, submit, input, change, dragstart, dragover, and drop. Unsupported static event names are build errors.

req: convention/003

003 data-slhx-debounce and data-slhx-throttle support simple millisecond values. No trigger mini-language in core.

req: convention/004

004 data-slhx-confirm dispatches a native confirm() before handler dispatch. Static empty confirmation messages are build errors because they silently disable the guard in browsers. Custom confirm UI belongs to integration crates.

req: convention/005

005 data-slhx-every dispatches a handle at a fixed interval while the element remains in the document. Duplicate timers per root are avoided.

req: convention/006

006 Request concurrency policy (latest, queue, drop, parallel) may be declared per handle with data-slhx-policy. Default for debounced/input handlers is latest; default for form submit is drop while pending. Stale EffectBatches from superseded requests must not be applied.

req: convention/007

007 Pending indicators are cosmetic only. The runtime toggles pending classes, indicator visibility, and disabled controls around request/effect execution; handler semantics are unchanged.

req: convention/008

008 data-slhx-disable-while-pending disables the triggering form controls or button while the request is active and restores them afterward.

req: convention/009

009 Unknown data-slhx-* authoring attributes are build errors with a suggested fix. slhx-owned attributes are a checked contract, not a silent extension namespace; opaque/integration islands should use explicit allowed attributes or their own non-slhx data-* names.


multipart

req: multipart/001

001 slhx-axum supports multipart/form-data as an integration boundary for file uploads.

req: multipart/002

002 Multipart parsing belongs to slhx-axum/axum extractors, not slhx-core.

req: multipart/003

003 File upload forms preserve native browser fallback behavior.


target_policy

req: target/001

001 slhx does not support response-side selector retargeting. Handlers choose targets by returning effects for generated resources.

req: target/002

002 slhx does not implement response-side CSS fragment selection in core. Servers return explicit component fragments or EffectBatches.