commit bd13d0fa0d99142d87877ccc08a0f308c25a0ae2 Author: Thomas Hain Date: Sun May 10 11:33:26 2026 +0200 feat: slhx requirements, project structure, and core primitives diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..56e8944 --- /dev/null +++ b/.githooks/commit-msg @@ -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 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..a8342ad --- /dev/null +++ b/.githooks/pre-commit @@ -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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..825cb48 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..dd7fef7 --- /dev/null +++ b/AGENTS.md @@ -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` 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`). + +### req:_form/003 + +- **003** Handler signature mismatch between generated `Form` and the handler parameter is a `cargo check` error. + +### req:_form/004 + +- **004** Progressive enhancement: if JS fails, `
` 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` / `Query` / `Mutation`. 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` 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`. 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 ` + +``` + +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` compile-time | +| Forms | React Hook Form | HTML native, but no validation bridge | `Fork` 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. +```