diff --git a/Cargo.lock b/Cargo.lock index 8c1e707..843c5aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,7 +401,6 @@ dependencies = [ name = "hemplate-core" version = "0.1.0" dependencies = [ - "cc", "hemplate-parser", "thiserror 2.0.18", "tree-sitter", @@ -1397,6 +1396,7 @@ dependencies = [ name = "slhx-derive" version = "0.1.0" dependencies = [ + "proc-macro2", "quote", "syn", ] diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index b2e481a..50f5a09 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -1,9 +1,11 @@ # 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. +slhx makes server-rendered HTML feel like it grew just enough interactivity. +App authors change state in Rust, render hemplate partials, and return generated +UI intent; the runtime swaps generated slots without selectors, a VDOM, or a +client app state framework. -> **hemplate owns syntax. hemplate emits surface. slhx consumes surface. slhx owns semantics. JS executes bytecode.** +> **hemplate owns syntax. hemplate emits surface. slhx consumes surface. slhx owns semantics. JS applies effects.** --- @@ -29,7 +31,7 @@ slhx competes with React by making frontend frameworks unnecessary for most apps ## 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] +001 slhx is checked hypermedia for Rust. Write `.heml`, write Rust handlers, return generated UI commands. The compiler checks every cross-file reference. The browser runtime only sees lowered ids and effect bytes. No JS app code is required for ordinary server-first apps. [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] @@ -38,33 +40,61 @@ slhx competes with React by making frontend frameworks unnecessary for most apps 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] +004 The north-star feel: **boring server-rendered HTML with typed, selectorless partial swaps.** Short handler bodies, compile-checked HTML contracts, plain Rust state changes, hemplate-rendered partials, and a tiny runtime that applies effects. [north_star] + +--- + +## canonical_authoring + +### req: canonical/001 +001 The canonical app shape is templates plus Rust, not a frontend folder: `.heml` files declare `data-slhx-root`, `data-slhx-slot`, `data-slhx-handle`, `data-slhx-form`, `h-key`, optional pending/page/island facts, and handlers return generated UI commands. Ordinary app code avoids selectors, numeric ids, raw effects, wire formats, manual registries/form parsing, raw `SafeHtml`, and raw render calls. Plain CSS owns appearance. [north_star] + +### req: canonical/002 +002 Typed partial swaps are the primary UX, not an advanced feature: handlers change domain state in Rust, convert domain values into view values, render hemplate partials through generated helpers, and place them into generated targets. The real primitive is generated target + rendered partial + swap kind. [north_star] + +### req: canonical/003 +003 Canonical keyed-row CRUD reads like ordinary Rust intent: create appends a rendered row partial, update/toggle replaces a keyed row partial, delete removes a keyed row, summary/text/form effects compose in tuples or arrays implementing `IntoEffect`, and no handler chooses a target with a CSS selector. [north_star] + +### req: canonical/004 +004 Generated helpers may compose only facts uniquely known from templates and checked Rust types: template, slot, optional key, form/control, class token, explicit island/event marker, and effect kind. If a handler parameter, key, form, target, raw route, or legacy target would require guessing, the user must say it explicitly and diagnostics must point to the Rust and hemplate spans. [north_star] + +### req: canonical/005 +005 Good generated helpers name UI intent without mixing domain work: `ui::content.replace(page)`, `ui::todos.append(todo)`, `ui::todo_row.replace(todo)`, `ui::todo_row.remove(todo_id)`, `ui::summary.set(text)`, `ui::notice.set("Saved")`, `ui::new_todo.clear()`, `ui::new_todo.focus("title")`, `ui::modal.replace(view)`, `ui::errors.set(errors)`, `ui::chart_path.set_attr("d", path)`, and `ui::game.emit(event)`. Helpers such as `refresh`, `save_and_update`, `sync_component`, and generic `rerender` are forbidden because they mix persistence, routing, rendering, target selection, or domain policy. [north_star] + +### req: canonical/006 +006 There is no separate beginner API and expert API. The simple generated shape is canonical and should scale: generated slots, partials, forms, class constants, islands/events, and page helpers are normal. Explicit primitives, raw targets, raw HTML, raw effects, manual registries/form parsing, low-level ids/opcodes, wire formats, and raw routes remain named escape hatches or internals around the same render/target/effect/transport model. [north_star] + +### req: canonical/007 +007 Opaque islands are explicit leaf adapters: templates declare `data-slhx-island` and optional generated handles/events; server code may emit snapshots/events such as `ui::game.emit(event)`, while island JS owns only high-frequency local behavior. Islands do not introduce a component runtime, client state graph, VDOM, or second UI model. [north_star] + +### req: canonical/008 +008 Offline/PWA support is opt-in adapter territory. Server-first slhx may fail interactions while offline; cached shells and local-sync queues live in crates such as `slhx-pwa` or `slhx-sync`, reuse generated slots/effects, queue explicit patches, and reconcile with server-canonical effects. Core slhx must not gain a mandatory client state graph, scheduler, CRDT, or local app runtime. [north_star] --- ## modes ### req: mode/001 -001 slhx has two happy paths: Page Enhancer and Interaction Handler. +001 slhx has one core authoring loop: render a partial and place it into a generated target with a swap kind. HTTP handlers, page navigation, push streams, and island events are transport/adapters around that loop. ### 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. +002 Page Enhancer mode is a specialized partial swap for navigation: it updates generated page/content/title/nav targets and adds history, scroll, shell, and fallback behavior. Authors use real anchors with `data-slhx-nav` or `data-slhx-boost`; no user-authored handler is required for ordinary navigation. ### req: mode/003 -003 Interaction Handler mode handles forms, buttons, typed params, and targeted updates through `#[slhx::handler]`. +003 Interaction Handler mode handles forms, buttons, typed params, and generated partial/text/form/island effects through Rust handlers. ### req: mode/004 -004 Beginner docs must teach Page Enhancer first, Interaction Handler second, Atoms third, client-local/WASM fourth, sync last. +004 Beginner docs must teach server-first typed partial swaps first, Page Enhancer as navigation around the same slot/effect model, explicit islands for leaf widgets, client-local/WASM only for local high-frequency behavior, and sync/offline last as opt-in adapters. --- ## 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] +001 The common case must feel like writing server-rendered HTML plus tiny Rust handlers: template, state, hemplate partials, and generated UI swaps. Users should not need to understand Surface IR, ResourceId, EffectWriter, postcard, runtime opcodes, selector targeting, or manual registries 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] +002 The happy path is: write `.heml`, write a Rust handler, return generated partial/text/form/page/island commands. No manual ids, no manual registry, no manual serialization, no CSS selector targets, no raw render calls, and no manual JavaScript for ordinary app UI. [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)`. @@ -76,7 +106,7 @@ slhx competes with React by making frontend frameworks unnecessary for most apps 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 target objects are the preferred authoring API: `targets::todo_list.put(&view)`, `targets::card.append(key, &view)`, `targets::count.text(42)`, `atoms::user.set(user)`. The public facade exposes `render(view)` for trusted hemplate-to-`SafeHtml` page and fragment composition; generated view modules expose lower-aware target methods plus compatibility functions `render(view)`, `put(slot, view)`, `append/prepend/replace(keyed_slot, key, view)`, `static_fragment(include_str!(...))` for prototype/static `.heml` fragments that need generated resource lowering as `SafeHtml`, and `lower(html)` for callers that need the lowered string. `render_html(view)` and `lower_html(html)` remain doc-hidden compatibility aliases and are not beginner-prelude exports. The beginner prelude should not expose lower-level slot render shortcuts that bypass generated lowering. These return `impl IntoEffect`, `SafeHtml`, or lowered HTML at the boundary. Raw `Effect` constructors, opcodes, and `EffectWriter` remain low-level. [north_star] +006 Generated object-like helpers are the preferred authoring API and are re-exported at the component root: `todos.append(todo)`, `todo_row.replace(todo)`, `todo_row.remove(todo_id)`, `summary.set(text)`, `new_todo.clear()`, `new_todo.focus("title")`, `page.replace(view)`, and `game.emit(event)`. These helpers hide hemplate rendering and resource lowering in the common path. Namespaced `targets`, `handles`, `forms`, and raw `advanced::slots` modules remain compatibility/organization and escape-hatch surfaces, not the ordinary call-site shape. The public facade may expose explicit `render(view)`, `target(name)`, `html(value)`, `lower(html)`, or raw effect constructors only as named escape hatches; they must not appear in beginner-prelude exports, canonical handler examples, or ordinary docs. [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. @@ -105,7 +135,7 @@ slhx competes with React by making frontend frameworks unnecessary for most apps ## progressive_disclosure ### req: pd/001 -001 A beginner can build CRUD with only: `.heml`, `#[slhx::handler]`, `Form`, generated `slots::*` methods, and `impl IntoEffect`. +001 A beginner can build CRUD with only: `.heml`, Rust handlers, `Form` or typed params, generated object-like UI helpers, 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. @@ -114,23 +144,23 @@ slhx competes with React by making frontend frameworks unnecessary for most apps 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. +004 Documentation must present levels as adapters around the same core: server-first partial swaps; cached Page/PWA shell; explicit leaf islands or client-local handlers for high-frequency behavior; hybrid sync/offline queues last. 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. +001 Page swapping is a first-class specialization of partial swapping: render a page partial, place it into generated page targets, then apply history/title/scroll/shell behavior. 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. +002 A `data-slhx-nav` click fetches the target URL as a slhx partial request. The response is conceptually `ui::content.replace(page)` plus optional generated nav/title targets and a `Navigate` effect; it must not introduce selector targeting or a second page-specific UI model. ### req: page_swap/003 -003 Page swapping uses generated slots, not CSS selectors. The default content target is the slot named `content`, not `#content`. +003 Page swapping uses generated targets, not CSS selectors. The default content target is the generated slot named `content`, not `#content`; explicit page helpers such as `ui::content.page(req, view)` or `request.page_html(ui::content.render(view), shell)` are adapters around the same partial-swap primitive. ### 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. +004 Ordinary page navigation must not require user-authored handlers. Explicit navigation handlers are available only when custom application logic is needed, and they still return generated target/page commands. ### 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. @@ -149,10 +179,10 @@ slhx competes with React by making frontend frameworks unnecessary for most apps 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. +002 Easy equivalents must exist for: generated target replacement, append/prepend/remove, boosted links/forms, page swap, form submit, loading indicators, confirmation, debounce/throttle, polling, history navigation, multi-target updates, response events, SSE/push, validation errors, modals, toasts, table rows, SVG fragments, and form error regions. ### 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. +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 targets, typed params, forms, explicit handlers, and page/push adapters around partial swaps. --- @@ -166,8 +196,8 @@ slhx competes with React by making frontend frameworks unnecessary for most apps ### 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. +`ui::todo_list::todo_row`, `ui::todo_list::create`, `ui::todo_list::new_todo`, and `ui::todo_list::COMPONENT` as a checked `ComponentRef`. +Category modules such as `targets`, `handles`, and `forms` remain available for organization/compatibility, while raw slot constants live under `advanced::slots`; 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. @@ -226,7 +256,7 @@ a `data-*` handle param is statically known or runtime-extracted. 001 `slhx_build` generates three artifacts from the generic Surface IR: (a) `slhx.generated.rs` containing ergonomic resource modules (`slots`, `targets`, `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 view modules expose ergonomic target objects and resource commands: `targets::list.put(value)`, `targets::row.append(key, value)`, `targets::row.replace(key, value)`, plus compatibility commands `put(slot, value)`, `append(keyed_slot, key, value)`, `prepend(keyed_slot, key, value)`, and `replace(keyed_slot, key, value)`. String-keyed target objects accept displayable domain ids without caller-side `.to_string()` noise. Commands return `impl IntoEffect` and preserve generated lowering. +002 Generated view modules expose ergonomic root-level target objects and commands that hide render/lower details: text slots provide `set(text)`, singleton partial slots provide `replace(view)`/`clear()`, keyed collection slots provide `append(view)`, `prepend(view)`, `replace(view)`, `remove(key_or_view)`, and forms provide `clear()`/`clear(field)`/`focus(field)`. String-keyed target objects accept displayable domain ids without caller-side `.to_string()` noise. Commands return `impl IntoEffect`, compose in plain Rust, preserve generated lowering, and fail to generate when the template lacks enough facts to infer the slot, key, form, or renderable view type. ### req: codegen/003 003 Generated module `handles` exports typed constants: `Handle` where `I` is `Form`, a param type, or `()`. Users rarely reference handles directly; they are consumed by `#[slhx::handler]` for validation. @@ -245,7 +275,7 @@ a `data-*` handle param is statically known or runtime-extracted. ## 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. +001 The generated API is the primary public authoring API. Most user code should return generated partial, text, keyed-row, form, page, nav, or island/event commands, not raw `Effect` constructors or raw render/lower calls. ### 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. @@ -256,6 +286,9 @@ a `data-*` handle param is statically known or runtime-extracted. ### req: public_api/004 004 If a common UI operation requires raw `EffectWriter`, the public API is considered incomplete. +### req: public_api/005 +005 Beginner-facing page/template composition uses generated render or page helpers. Direct `SafeHtml` construction, raw `html(...)`, raw `target(...)`, raw route fragments, and explicit `ui::render(...)` calls are advanced escape hatches and must not appear in beginner examples or docs. + --- ## effect_algebra @@ -315,7 +348,7 @@ resources. Concrete runtime targets are addressed through `ResourceRef` 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. +003 Generated helpers for keyed slots are `append(view)`, `prepend(view)`, `replace(view)`, and `remove(key_or_view)` when the template and view type provide an unambiguous `h-key`. Compatibility functions such as `append(keyed_slot, key, view)` may exist as explicit low-level forms. Mismatch between key type and slot key type is a compile-time error, and missing/ambiguous keys are build errors with template spans. --- @@ -346,7 +379,7 @@ what a valid business email is. ## 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()`. +001 Generated form APIs provide common commands: `clear()`, `clear(field)`, `reset()`, `error(field, message)`, `focus(field)`, and `disable_while_pending()`. `form.clear()` clears the canonical generated form without requiring callers to name raw control ids. ### req: form_effects/002 002 Form effects target generated form/control ids, not CSS selectors. @@ -435,13 +468,13 @@ what a valid business email is. ## 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. +001 slhx-axum supports the common full-shell/partial pattern as an adapter around generated partial swaps. Full-page requests are wrapped in a user-provided Shell; slhx partial requests may return a rendered partial for a generated target or an EffectBatch. Page helpers add shell/title/history/fallback behavior without changing the render/target/effect model. ### 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. Integration registration should read as interactions over generated handles, not low-level registry wiring. +003 Interactive fragments that would traditionally be implemented as `/demo/...` HTMX endpoints should be expressible as Rust handlers returning generated target commands. Integration registration should read as interactions over generated handles and page/partial helpers, not low-level registry wiring. ### req: axum/004 004 Query-string demo endpoints may be migrated to typed handler params from `data-*` attributes or forms. `Query` remains available in normal Axum routes but is not the slhx happy path. @@ -476,10 +509,10 @@ what a valid business email is. 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. +003 Push swaps are ordinary partial swaps carried over SSE/WebSocket: streamed effects target generated slots, atoms, or island events such as `ui::feed.prepend(event)` or `ui::game.emit(snapshot)`. No selector-based `sse-swap` semantics in core. ### req: push/004 -004 Out-of-band updates are ordinary multi-target EffectBatches. +004 Out-of-band updates are ordinary multi-target partial swaps/effects, not a separate response model. ### 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. @@ -520,19 +553,28 @@ what a valid business email is. ## 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] +001 `Effect::event` and generated event helpers are the single slhx-to-widget bridge. External widgets, charts, games, maps, Alpine/Svelte islands, and Web Components listen via native `CustomEvent`; slhx core does not inspect their state, rendering internals, or framework lifecycle. [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. +002 Web Components and custom elements are valid opaque leaf nodes. slhx does not inspect shadow DOM or mutate inside custom elements unless the author explicitly exposes slhx-owned slots/handles at the boundary. 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. +003 WASM islands and third-party framework islands are explicit leaf boundaries. slhx may replace the island root as a generated target, but it does not manage inside it; slhx owns the generated slot/island boundary, Alpine/Svelte/Web Components/hand-written widgets own the inside, and events cross the boundary. Commands flow widget-to-slhx through explicit generated handles or `slhx.send(...)`, and server-to-widget through generated event helpers such as `ui::chart.emit(snapshot)`. ### 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 `CustomEvent`s scoped to the slhx root. +005 HTMX-style response triggers and widget notifications are represented by `Effect::event` or generated event helpers. Events are native `CustomEvent`s scoped to the slhx root. + +### req: interop/006 +006 `data-slhx-preserve` is an explicit preserve boundary for rare leaf-widget cases where slhx updates around a subtree without destroying it. Preserve semantics must be simple: preserve the marked subtree identity, do not diff or hydrate inside it, and require authors to mark the boundary deliberately. Preserve must not become a default lifecycle model or a workaround for unclear ownership. + +### req: interop/007 +007 The runtime emits native lifecycle events such as `slhx:before-swap`, `slhx:after-swap`, `slhx:event`, `slhx:connect`, and `slhx:disconnect` so Alpine, Svelte, Web Components, and hand-written widgets can attach at DOM/event boundaries. slhx core must not add framework-specific adapters. + +### req: interop/008 +008 Interop must prevent selector hacks, manual JS reinitialization races, lost widget state after swaps, and double-owned state by making ownership explicit: slhx owns generated server DOM targets, the external widget owns explicit leaves, and events are the supported crossing point. Core must not add selector targeting, hydration compatibility, a client store, or a framework lifecycle to make interop easy. --- @@ -594,7 +636,7 @@ what a valid business email is. ## 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. +001 `EffectWriter` implements a test backend so handlers can be unit-tested without a browser: `slhx_test::run(handler, input)`, `slhx_test::inspect_batch(dispatched_batch)`, and `slhx_test::inspect_wire(bytes)` return an `EffectInspector` with generated-resource assertions such as `has_target(generated_target)`, `updates_text(generated_target)`, `updates_html(generated_target)`, `target_html_containing(generated_target, text)`, `inserts_html_containing(generated_target, key, text)`, `replaces_keyed_html_containing(generated_target, key, text)`, `removes_key(generated_target, key)`, `pushes_to(url)`, `emits(name, payload)`, `emits_containing(name, text)`, `has_slot(slot)`, `has_atom(atom)`, etc. Canonical example tests should prefer generated-target assertions so tests use the same generated target objects as handlers instead of importing raw slot constants or matching raw effects/payloads. Browser/E2E selector helpers are allowed only as low-level test adapters for driving rendered HTML and must be generated or named around public authoring concepts such as handles, targets, forms, nav links, roots, islands, class tokens, or keys; they must not become app authoring APIs or teach selector targeting. ### 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. @@ -635,7 +677,7 @@ what a valid business email is. 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`. +003 The JS runtime may maintain a narrow atom value table keyed by `AtomId` only for explicit `Atom` resources and SSR bootstrap. This is not an app state framework, component store, cache, or reactive graph. 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. @@ -823,31 +865,31 @@ Optional ergonomic macros may exist: `#[slhx::surface]`, `#[slhx::component]`, 003 Handler parameters are inferred from four sources: `Form`, `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: +004 The common handler signature forms are plain Rust functions, synchronous or async: ```rust -fn my_handler() -> impl IntoEffect -fn my_handler(form: Form) -> impl IntoEffect -fn my_handler(app: &mut AppState) -> impl IntoEffect -fn my_handler(card_id: CardId, app: &mut AppState) -> impl IntoEffect +fn ping() -> impl IntoEffect +async fn add(app: State, form: Form) -> impl IntoEffect +async fn rename(app: State, todo_id: TodoId, title: Title) -> impl IntoEffect +async fn delete(app: State, todo_id: TodoId) -> impl IntoEffect ``` -All forms support returning `impl IntoEffect` and compose through tuples. +`State` is illustrative integration context; equivalent framework extractors or app references are adapter concerns. Form fields and `data-*` params parse through normal Rust `FromForm`/`FormValue`/`FromStr`-style traits, so domain newtypes remain user-authored. Handlers may return `impl IntoEffect` or `Result` for fallible database/domain work; `IntoEffect` values compose through tuples while `Result` paths preserve a typed error boundary for integrations to map to form errors, toasts, events, or HTTP responses. --- ## 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. +001 `#[slhx::app(...)]` marks an application/root registry entry point and composes generated component handler modules into one app registry from the app state. 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 or handwritten chains of per-component registration calls in canonical app code. --- ## 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. +001 slhx does not support selector targets such as `closest`, `find`, or `this` in core. The equivalent pattern is a named generated target on the local component, keyed row, modal, toast, SVG fragment, form error region, or page content area. ### req: locality/002 -002 Diagnostics should suggest adding `data-slhx-slot` to the local element when users attempt a self/row update pattern. +002 Diagnostics should suggest adding `data-slhx-slot` or `h-key` to the local element when users attempt a self/row update pattern, and should explain which generated target helper would become available. --- @@ -911,7 +953,7 @@ All forms support returning `impl IntoEffect` and compose through tuples. ## target_policy ### req: target/001 -001 slhx does not support response-side selector retargeting. Handlers choose targets by returning effects for generated resources. +001 slhx does not support response-side selector retargeting. Handlers choose targets by returning generated UI commands 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. +002 slhx does not implement response-side CSS fragment selection in core. Servers return explicit hemplate partials for generated targets or EffectBatches containing generated target effects. diff --git a/examples/kanban.md b/examples/kanban.md index dc0911a..9c346fa 100644 --- a/examples/kanban.md +++ b/examples/kanban.md @@ -4,7 +4,7 @@ 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. +This is an explicitly advanced/low-level north-star boundary sketch for slhx + hemplate + slhx-sync, not the beginner-facing authoring path. Raw sync/effect/wire vocabulary below is excluded from beginner-facing examples by design. --- @@ -270,24 +270,27 @@ The runtime does not know "presence". It executes generated DOM updates. --- -## 9. What the browser receives +## 9. What app authors write; what the browser receives -Initial SSR: +Initial SSR stays an ordinary rendered template with symbolic slhx attributes at +the authoring boundary: ```html -
+
... -
- Fix login bug +
+ {+ card.title +}
...
``` -Runtime attachment: `/slhx.js` installs delegated root listeners for forms, -clicks, and pointer/drag events. App authors keep composing generated resources; -they do not attach per-node listeners or write selector glue. +The compiler lowers those symbols to compact runtime metadata, but that metadata +is not an app-authoring contract. Runtime attachment: `/slhx.js` installs +delegated root listeners for forms, clicks, and pointer/drag events. App authors +keep composing generated resources; they do not attach per-node listeners, copy +numeric ids, or write selector glue. No framework download. No VDOM. No hydration. No game loop. diff --git a/examples/kanban/src/lib.rs b/examples/kanban/src/lib.rs index 63e34da..c27ba53 100644 --- a/examples/kanban/src/lib.rs +++ b/examples/kanban/src/lib.rs @@ -6,7 +6,7 @@ mod tests { use super::ui::{board, board_card}; use hemplate::Hemplate; use scraper::{Html, Selector}; - use slhx::{Effect, IntoEffect, Payload}; + use slhx::IntoEffect; use slhx_test::inspect; #[derive(Hemplate)] @@ -27,12 +27,11 @@ mod tests { #[test] fn kanban_board_updates_generated_slot() { fn render_board() -> impl IntoEffect { - board::targets::board.put(&empty_board()) + board::board.put(&empty_board()) } let effect = inspect(render_board()); - assert!(effect.has_slot(board::slots::board)); - assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Html(_), .. }])); + assert!(effect.updates_html(board::board)); } // req: html_safety/002 req: view/001 req: test/005 @@ -45,7 +44,9 @@ mod tests { fn empty_board() -> BoardColumns { // req: html_safety/002 req: view/001 - BoardColumns { columns: Vec::new() } + BoardColumns { + columns: Vec::new(), + } } fn selector(value: &str) -> Selector { @@ -57,19 +58,21 @@ mod tests { fn kanban_form_handler_is_checked_against_hemplate_form() { #[slhx::handler] fn create_card(_form: slhx::Form) -> impl IntoEffect { - board::slots::notice.text("queued") + board::notice.text("queued") } let effect = inspect(create_card(CreateCard::FORM)); - assert!(effect.has_slot(board::slots::notice)); - assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }])); + assert!(effect.updates_text(board::notice)); } // req: examples/001 req: form/002 req: codegen/003 #[test] fn kanban_template_exports_form_and_card_handles() { - assert_ne!(board::handles::create_card.id(), board_card::handles::move_right.id()); - assert_eq!(board::forms::create_card.field("title").resource, board::forms::create_card.id()); + assert_ne!(board::create_card.id(), board_card::move_right.id()); + assert_eq!( + board::create_card_form.field("title").resource, + board::create_card_form.id() + ); } } diff --git a/examples/kanban/src/main.rs b/examples/kanban/src/main.rs index 2855f1f..fd0830d 100644 --- a/examples/kanban/src/main.rs +++ b/examples/kanban/src/main.rs @@ -4,14 +4,14 @@ use axum::routing::get; use axum::Router; use futures_util::{stream, StreamExt}; use hemplate::Hemplate; -use slhx::{IntoEffect, SafeHtml}; +use slhx::{Html, IntoEffect}; use slhx_axum::{ interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse, InteractionRequest, PageRequest, }; +use slhx_kanban_example::ui::board::{self as board}; +use slhx_kanban_example::ui::board_card as card_board; use slhx_kanban_example::ui::{self, board as board_ui}; -use slhx_kanban_example::ui::board::{forms, handles, slots, targets}; -use slhx_kanban_example::ui::board_card::handles as card_handles; use std::collections::BTreeMap; use std::convert::Infallible; use std::net::SocketAddr; @@ -40,7 +40,7 @@ struct Card { #[derive(Hemplate)] struct AppShell { - body: SafeHtml, + body: Html, } #[derive(Hemplate)] @@ -67,8 +67,8 @@ struct BoardCard { #[derive(Hemplate)] struct Board { - options: SafeHtml, - board: SafeHtml, + options: Html, + board: Html, } #[derive(Hemplate)] @@ -96,9 +96,21 @@ async fn main() { board: Mutex::new(BoardState { next_id: 4, cards: vec![ - Card { id: 1, title: "Write requirements".into(), column: 0 }, - Card { id: 2, title: "Build browser example".into(), column: 1 }, - Card { id: 3, title: "Verify with HTTP".into(), column: 2 }, + Card { + id: 1, + title: "Write requirements".into(), + column: 0, + }, + Card { + id: 2, + title: "Build browser example".into(), + column: 1, + }, + Card { + id: 3, + title: "Verify with HTTP".into(), + column: 2, + }, ], }), }); @@ -138,14 +150,20 @@ async fn interact( // req: push/001 req: push/003 req: examples/001 async fn events(Query(params): Query>) -> impl IntoResponse { if params.contains_key("once") { - let effect = targets::presence.put(&Presence { count: 1 }); - return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed()); + let effect = board::presence.put(&Presence { count: 1 }); + return sse(stream::iter([Ok::<_, Infallible>( + effect.into_batch(ui::BUILD_FINGERPRINT), + )]) + .boxed()); } let batches = stream::unfold(1_u64, |count| async move { tokio::time::sleep(Duration::from_secs(4)).await; - let effect = targets::presence.put(&Presence { count }); - Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1)) + let effect = board::presence.put(&Presence { count }); + Some(( + Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), + count + 1, + )) }) .boxed(); sse(batches) @@ -153,7 +171,7 @@ async fn events(Query(params): Query>) -> impl IntoResp fn registry(state: Arc) -> impl DispatchRegistry { interactions(ui::BUILD_FINGERPRINT) - .on(handles::create_card, { + .on(board::create_card, { let state = state.clone(); move |form| { // req: examples/001 req: form/002 @@ -163,12 +181,16 @@ fn registry(state: Arc) -> impl DispatchRegistry { if !title.is_empty() { let id = board.next_id; board.next_id += 1; - board.cards.push(Card { id, title: title.into(), column }); + board.cards.push(Card { + id, + title: title.into(), + column, + }); } board_effects(&board, "Card added") } }) - .on(card_handles::move_left, { + .on(card_board::move_left, { let state = state.clone(); move |form| { // req: examples/001 req: list/003 @@ -176,10 +198,17 @@ fn registry(state: Arc) -> impl DispatchRegistry { let moved = update_card(&mut board, form.parse("card_id"), |card| { card.column = card.column.saturating_sub(1); }); - board_effects(&board, if moved { "Card moved left" } else { "Card not found" }) + board_effects( + &board, + if moved { + "Card moved left" + } else { + "Card not found" + }, + ) } }) - .on(card_handles::move_right, { + .on(card_board::move_right, { let state = state.clone(); move |form| { // req: examples/001 req: list/003 @@ -187,10 +216,17 @@ fn registry(state: Arc) -> impl DispatchRegistry { let moved = update_card(&mut board, form.parse("card_id"), |card| { card.column = (card.column + 1).min(COLUMNS.len() - 1); }); - board_effects(&board, if moved { "Card moved right" } else { "Card not found" }) + board_effects( + &board, + if moved { + "Card moved right" + } else { + "Card not found" + }, + ) } }) - .on(card_handles::delete_card, { + .on(card_board::delete_card, { let state = state.clone(); move |form| { // req: examples/001 req: list/003 @@ -199,16 +235,23 @@ fn registry(state: Arc) -> impl DispatchRegistry { if let Some(id) = form.parse::("card_id") { board.cards.retain(|card| card.id != id); } - board_effects(&board, if board.cards.len() < before { "Card deleted" } else { "Card not found" }) + board_effects( + &board, + if board.cards.len() < before { + "Card deleted" + } else { + "Card not found" + }, + ) } }) } fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect { ( - targets::board.put(&board_view(board)), - slots::notice.text(notice), - forms::create_card.clear("title"), + board::board.put(&board_view(board)), + board::notice.text(notice), + board::create_card_form.clear(), ) } @@ -235,7 +278,7 @@ fn parse_column(value: Option<&str>) -> usize { .unwrap_or(0) } -fn page_html(board: &BoardState) -> SafeHtml { +fn page_html(board: &BoardState) -> Html { // req: html_safety/002 req: view/001 board_ui::render(&Board { options: render_options(), @@ -243,12 +286,12 @@ fn page_html(board: &BoardState) -> SafeHtml { }) } -fn shell(body: SafeHtml) -> SafeHtml { +fn shell(body: Html) -> Html { // req: html_safety/001 req: html_safety/002 req: axum_integration/001 slhx::render(&AppShell { body }) } -fn render_options() -> SafeHtml { +fn render_options() -> Html { // req: html_safety/002 req: view/001 ui::render(&ColumnOptions { options: COLUMNS @@ -290,6 +333,11 @@ fn render_card(card: &Card) -> BoardCard { mod tests { use super::*; use scraper::{Html, Selector}; + use slhx_test::{ + class_child_selector, disabled_button_selector, element_class_selector, + escaped_markup_selector, form_selector, keyed_selector, root_element_selector, + select_options_selector, small_text_selector, strong_text_selector, + }; fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") @@ -303,10 +351,22 @@ mod tests { assert!(!html.as_str().contains("__BOARD__")); let document = Html::parse_fragment(html.as_str()); - assert_eq!(document.select(&selector("section[data-slhx-root=\"kanban\"]")).count(), 1); - assert_eq!(document.select(&selector("select[name=\"column\"] > option")).count(), 3); - assert_eq!(document.select(&selector("[data-sid]")).count(), 3); - assert_eq!(document.select(&selector("[data-hid]")).count(), 1); + assert_eq!( + document + .select(&selector(&root_element_selector("section", "kanban"))) + .count(), + 1 + ); + assert_eq!( + document + .select(&selector(&select_options_selector("column"))) + .count(), + 3 + ); + assert_eq!( + document.select(&selector(&form_selector("header"))).count(), + 1 + ); } // req: html_safety/002 req: view/001 req: test/005 @@ -323,15 +383,31 @@ mod tests { let html = ui::render(&board_view(&board)); let document = Html::parse_fragment(html.as_str()); - assert_eq!(document.select(&selector(".columns > section.column")).count(), 3); + assert_eq!( + document + .select(&selector(&class_child_selector( + "columns", "section", "column" + ))) + .count(), + 3 + ); let card = document - .select(&selector("article.card[data-key=\"1\"]")) + .select(&selector(&keyed_selector("article.card", 1))) .next() .expect("card renders"); - let title = card.select(&selector("strong")).next().expect("card title renders"); + let title = card + .select(&selector(strong_text_selector())) + .next() + .expect("card title renders"); assert_eq!(title.text().collect::(), "Compile checked"); - assert!(card.select(&selector("b")).next().is_none()); - assert_eq!(card.select(&selector("button[disabled]")).count(), 1); + assert!(card + .select(&selector(&escaped_markup_selector("b"))) + .next() + .is_none()); + assert_eq!( + card.select(&selector(disabled_button_selector())).count(), + 1 + ); } // req: html_safety/002 req: view/001 req: test/005 @@ -339,10 +415,15 @@ mod tests { fn presence_payload_is_rendered_by_a_hemplate_view() { let html = ui::render(&Presence { count: 7 }); let document = Html::parse_fragment(html.as_str()); - assert_eq!(document.select(&selector("span.presence")).count(), 2); assert_eq!( document - .select(&selector("small")) + .select(&selector(&element_class_selector("span", "presence"))) + .count(), + 2 + ); + assert_eq!( + document + .select(&selector(small_text_selector())) .next() .map(|small| small.text().collect::()), Some("tick #7".to_owned()) diff --git a/examples/techdemo/README.md b/examples/techdemo/README.md index df36641..ff84877 100644 --- a/examples/techdemo/README.md +++ b/examples/techdemo/README.md @@ -18,7 +18,7 @@ This is a polished Linear-style product demo for planning typed work across lane - page-enhancer navigation with native link fallback - SSE server push into a generated slot - drag-and-drop lane moves persisted by typed server handlers through the slhx runtime -- an opaque canvas island fed by `Effect::event`/`CustomEvent`, without teaching slhx core about the widget +- an explicit advanced opaque canvas island fed by a generated event helper, without teaching slhx core about the widget - no user-authored browser JavaScript in slhx-managed UI; the island JavaScript is a leaf-widget escape hatch Verification: diff --git a/examples/techdemo/src/lib.rs b/examples/techdemo/src/lib.rs index d0b981d..9c9fa6d 100644 --- a/examples/techdemo/src/lib.rs +++ b/examples/techdemo/src/lib.rs @@ -3,11 +3,11 @@ pub mod ui {} #[cfg(test)] mod tests { - use super::ui::control_center::{forms, handles, slots, targets}; - use super::ui::issue_card::handles as card_handles; + use super::ui::control_center::{hero_metrics, launch_work, launch_work_form, notice}; + use super::ui::issue_card::advance_work; use super::ui::issue_lane::events as lane_events; use hemplate::Hemplate; - use slhx::{Effect, IntoEffect, Payload}; + use slhx::IntoEffect; use slhx_test::inspect; #[derive(Hemplate)] @@ -30,14 +30,14 @@ mod tests { fn techdemo_uses_generated_slots_for_multi_target_updates() { fn update() -> impl IntoEffect { ( - targets::hero_metrics.put(&FastMetric { label: "fast" }), - slots::notice.text("typed"), + hero_metrics.put(&FastMetric { label: "fast" }), + notice.text("typed"), ) } let batch = inspect(update()); - assert!(batch.has_slot(slots::hero_metrics)); - assert!(batch.has_slot(slots::notice)); + assert!(batch.has_target(hero_metrics)); + assert!(batch.has_target(notice)); } // req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003 @@ -45,22 +45,21 @@ mod tests { fn techdemo_form_handler_is_checked_against_hemplate_form() { #[slhx::handler] fn launch_work(_form: slhx::Form) -> impl IntoEffect { - slots::notice.text("queued") + notice.text("queued") } let batch = inspect(launch_work(LaunchWork::FORM)); - assert!(batch.has_slot(slots::notice)); - assert!(matches!(batch.ops(), [Effect::Put { payload: Payload::Text(_), .. }])); + assert!(batch.updates_text(notice)); } // req: examples/001 req: form/002 req: codegen/003 #[test] fn techdemo_exports_form_and_interaction_handles() { - assert_ne!(handles::launch_work.id(), card_handles::advance_work.id()); + assert_ne!(launch_work.id(), advance_work.id()); assert_eq!( - forms::launch_work.field("title").resource, - forms::launch_work.id() + launch_work_form.field("title").resource, + launch_work_form.id() ); } @@ -68,9 +67,7 @@ mod tests { #[test] fn techdemo_exports_generated_event_constants() { assert_eq!(lane_events::drop.as_str(), "drop"); - assert!(matches!( - slhx::event(lane_events::drop, "card-1"), - Effect::Emit { name, payload } if name == "drop" && payload == "card-1" - )); + let event = inspect(lane_events::drop.emit("card-1")); + assert!(event.emits("drop", "card-1")); } } diff --git a/examples/techdemo/src/main.rs b/examples/techdemo/src/main.rs index a3520a9..ede093e 100644 --- a/examples/techdemo/src/main.rs +++ b/examples/techdemo/src/main.rs @@ -1,18 +1,18 @@ use axum::extract::{Query, State}; +use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::get; use axum::Router; use futures_util::{stream, StreamExt}; use hemplate::Hemplate; -use slhx::{CssClass, CssClasses, IntoEffect, SafeHtml}; +use slhx::{CssClass, CssClasses, EventName, Html, IntoEffect}; use slhx_axum::{ interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse, InteractionRequest, PageRequest, }; use slhx_techdemo::ui; -use slhx_techdemo::ui::control_center::{classes, forms, handles, slots, targets}; -use slhx_techdemo::ui::issue_card::handles as card_handles; -use slhx_techdemo::ui::issue_lane::handles as lane_handles; +use slhx_techdemo::ui::control_center::{self as control, classes}; +use slhx_techdemo::ui::{issue_card as card_control, issue_lane as lane_control}; use std::collections::{BTreeMap, VecDeque}; use std::convert::Infallible; use std::net::SocketAddr; @@ -24,6 +24,7 @@ const LANES: [(&str, &str, &str); 3] = [ ("runtime", "Runtime", "typed updates → DOM"), ("product", "Product", "Native UX, zero app JS"), ]; +const ISLAND_ORBIT: EventName = EventName::new("slhx:island-orbit"); #[derive(Clone)] struct WorkItem { @@ -73,9 +74,27 @@ impl Default for DemoState { let mut state = Self { next_id: 4, work: vec![ - WorkItem { id: 1, title: "Compile checked handles".into(), lane: 0, impact: 9, stage: Stage::Shipped }, - WorkItem { id: 2, title: "Stream typed presence".into(), lane: 1, impact: 7, stage: Stage::Active }, - WorkItem { id: 3, title: "Replace dashboard widgets".into(), lane: 2, impact: 8, stage: Stage::Draft }, + WorkItem { + id: 1, + title: "Compile checked handles".into(), + lane: 0, + impact: 9, + stage: Stage::Shipped, + }, + WorkItem { + id: 2, + title: "Stream typed presence".into(), + lane: 1, + impact: 7, + stage: Stage::Active, + }, + WorkItem { + id: 3, + title: "Replace dashboard widgets".into(), + lane: 2, + impact: 8, + stage: Stage::Draft, + }, ], activity: VecDeque::new(), spotlight: "No selectors. Generated resources address every target.".into(), @@ -128,22 +147,22 @@ struct BoardLanes { #[derive(Hemplate)] struct ControlCenter { - hero: SafeHtml, - board: SafeHtml, - inspector: SafeHtml, - activity: SafeHtml, + hero: Html, + board: Html, + inspector: Html, + activity: Html, island_snapshot: String, } #[derive(Hemplate)] struct AppShell { - body: SafeHtml, + body: Html, } #[derive(Hemplate)] #[hemplate = "partials"] struct InspectorPanel { - selected: SafeHtml, + selected: Html, spotlight: String, } @@ -199,11 +218,14 @@ struct HeroMetrics { #[tokio::main] async fn main() { - let state = Arc::new(Shared { demo: Mutex::new(DemoState::default()) }); + let state = Arc::new(Shared { + demo: Mutex::new(DemoState::default()), + }); let app = Router::new() .route("/", get(home).post(interact)) .route("/architecture", get(architecture)) .route("/events", get(events)) + .route("/favicon.ico", get(favicon)) .route("/slhx.js", get(runtime)) .route("/app.css", get(app_css)) .route("/control_center.css", get(control_center_css)) @@ -247,16 +269,29 @@ async fn runtime() -> impl IntoResponse { runtime_js() } +async fn favicon() -> StatusCode { + StatusCode::NO_CONTENT +} + async fn app_css() -> impl IntoResponse { - ([("content-type", "text/css; charset=utf-8")], include_str!("../templates/app_shell.css")) + ( + [("content-type", "text/css; charset=utf-8")], + include_str!("../templates/app_shell.css"), + ) } async fn control_center_css() -> impl IntoResponse { - ([("content-type", "text/css; charset=utf-8")], include_str!("../templates/control_center.css")) + ( + [("content-type", "text/css; charset=utf-8")], + include_str!("../templates/control_center.css"), + ) } async fn island_js() -> impl IntoResponse { - ([("content-type", "text/javascript; charset=utf-8")], include_str!("../templates/island.js")) + ( + [("content-type", "text/javascript; charset=utf-8")], + include_str!("../templates/island.js"), + ) } async fn interact( @@ -269,14 +304,20 @@ async fn interact( // req: push/001 req: push/003 req: examples/001 async fn events(Query(params): Query>) -> impl IntoResponse { if params.contains_key("once") { - let effect = targets::live_feed.put(&LiveFeed { tick: 1 }); - return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed()); + let effect = control::live_feed.put(&LiveFeed { tick: 1 }); + return sse(stream::iter([Ok::<_, Infallible>( + effect.into_batch(ui::BUILD_FINGERPRINT), + )]) + .boxed()); } let batches = stream::unfold(1_u64, |tick| async move { tokio::time::sleep(Duration::from_secs(4)).await; - let effect = targets::live_feed.put(&LiveFeed { tick }); - Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), tick + 1)) + let effect = control::live_feed.put(&LiveFeed { tick }); + Some(( + Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), + tick + 1, + )) }) .boxed(); sse(batches) @@ -284,7 +325,7 @@ async fn events(Query(params): Query>) -> impl IntoResp fn registry(shared: Arc) -> impl DispatchRegistry { interactions(ui::BUILD_FINGERPRINT) - .on(handles::launch_work, { + .on(control::launch_work, { let shared = shared.clone(); move |form| { // req: form/002 req: examples/001 @@ -303,7 +344,7 @@ fn registry(shared: Arc) -> impl DispatchRegistry { demo_effects(&demo, "Launch accepted · 4 targets updated") } }) - .on(card_handles::advance_work, { + .on(card_control::advance_work, { let shared = shared.clone(); move |form| { // req: list/003 req: examples/001 @@ -321,7 +362,7 @@ fn registry(shared: Arc) -> impl DispatchRegistry { demo_effects(&demo, "Pipeline advanced") } }) - .on(lane_handles::move_to_lane, { + .on(lane_control::move_to_lane, { let shared = shared.clone(); move |form| { // req: list/003 req: examples/001 @@ -343,7 +384,7 @@ fn registry(shared: Arc) -> impl DispatchRegistry { demo_effects(&demo, "Drag-and-drop move persisted") } }) - .on(card_handles::delete_work, { + .on(card_control::delete_work, { let shared = shared.clone(); move |form| { // req: list/003 req: examples/001 @@ -362,7 +403,7 @@ fn registry(shared: Arc) -> impl DispatchRegistry { demo_effects(&demo, "Card removed") } }) - .on(card_handles::spotlight_work, { + .on(card_control::spotlight_work, { let shared = shared.clone(); move |form| { // req: examples/001 @@ -377,23 +418,23 @@ fn registry(shared: Arc) -> impl DispatchRegistry { demo_effects(&demo, "Inspector focused") } }) - .on(handles::simulate_push, { + .on(control::simulate_push, { let shared = shared.clone(); move |_| { // req: push/003 req: examples/001 let mut demo = shared.demo.lock().unwrap(); demo.log("Simulated push event produced the same generated update shape"); ( - targets::live_feed.put(&LiveFeed { + control::live_feed.put(&LiveFeed { tick: demo.activity.len() as u64, }), - targets::activity.put(&activity_view(&demo)), - slots::notice.text("Push simulated · no client app code"), - slhx::event("slhx:island-orbit", island_snapshot(&demo)), + control::activity.put(&activity_view(&demo)), + control::notice.text("Push simulated · no client app code"), + ISLAND_ORBIT.emit(island_snapshot(&demo)), ) } }) - .on(handles::reset_demo, { + .on(control::reset_demo, { let shared = shared.clone(); move |_| { // req: examples/001 @@ -404,7 +445,11 @@ fn registry(shared: Arc) -> impl DispatchRegistry { }) } -fn update_work(demo: &mut DemoState, id: Option, update: impl FnOnce(&mut WorkItem)) -> Option { +fn update_work( + demo: &mut DemoState, + id: Option, + update: impl FnOnce(&mut WorkItem), +) -> Option { let id = id?; let item = demo.work.iter_mut().find(|item| item.id == id)?; let title = item.title.clone(); @@ -414,26 +459,37 @@ fn update_work(demo: &mut DemoState, id: Option, update: impl FnOnce(&mut W fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect { ( - targets::hero_metrics.put(&hero_view(demo)), - targets::board.put(&board_view(demo)), - targets::activity.put(&activity_view(demo)), - targets::inspector.put(&inspector_view(demo)), - slots::notice.text(notice), - forms::launch_work.clear("title"), - slhx::event("slhx:island-orbit", island_snapshot(demo)), + control::hero_metrics.put(&hero_view(demo)), + control::board.put(&board_view(demo)), + control::activity.put(&activity_view(demo)), + control::inspector.put(&inspector_view(demo)), + control::notice.text(notice), + control::launch_work_form.clear(), + ISLAND_ORBIT.emit(island_snapshot(demo)), ) } fn parse_lane(value: Option<&str>) -> usize { let value = value.unwrap_or(LANES[0].0); - LANES.iter().position(|(id, _, _)| *id == value).unwrap_or(0) + LANES + .iter() + .position(|(id, _, _)| *id == value) + .unwrap_or(0) } fn island_snapshot(demo: &DemoState) -> String { // Opaque leaf-widget bridge: compact server snapshot in, native CustomEvent out. // req: interop/001 req: examples/001 - let active = demo.work.iter().filter(|item| item.stage == Stage::Active).count(); - let shipped = demo.work.iter().filter(|item| item.stage == Stage::Shipped).count(); + let active = demo + .work + .iter() + .filter(|item| item.stage == Stage::Active) + .count(); + let shipped = demo + .work + .iter() + .filter(|item| item.stage == Stage::Shipped) + .count(); let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum(); format!( "{}|{}|{}|{} active · {} shipped · {} activity rows", @@ -446,7 +502,7 @@ fn island_snapshot(demo: &DemoState) -> String { ) } -fn page_html(demo: &DemoState) -> SafeHtml { +fn page_html(demo: &DemoState) -> Html { // req: html_safety/002 req: view/001 render_control_center(ControlCenter { hero: render_hero(demo), @@ -457,15 +513,23 @@ fn page_html(demo: &DemoState) -> SafeHtml { }) } -fn shell(body: SafeHtml) -> SafeHtml { +fn shell(body: Html) -> Html { // req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003 ui::app_shell::render(&AppShell { body }) } fn hero_view(demo: &DemoState) -> HeroMetrics { // req: html_safety/002 req: view/001 - let shipped = demo.work.iter().filter(|item| item.stage == Stage::Shipped).count(); - let active = demo.work.iter().filter(|item| item.stage == Stage::Active).count(); + let shipped = demo + .work + .iter() + .filter(|item| item.stage == Stage::Shipped) + .count(); + let active = demo + .work + .iter() + .filter(|item| item.stage == Stage::Active) + .count(); let impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum(); HeroMetrics { resource_count: 13, @@ -475,7 +539,7 @@ fn hero_view(demo: &DemoState) -> HeroMetrics { } } -fn render_hero(demo: &DemoState) -> SafeHtml { +fn render_hero(demo: &DemoState) -> Html { // req: html_safety/002 req: view/001 req: component/003 ui::render(&hero_view(demo)) } @@ -519,7 +583,7 @@ fn activity_view(demo: &DemoState) -> ActivityFeed { } } -fn render_activity(demo: &DemoState) -> SafeHtml { +fn render_activity(demo: &DemoState) -> Html { // req: html_safety/002 req: view/001 req: component/003 ui::render(&activity_view(demo)) } @@ -546,32 +610,32 @@ fn inspector_view(demo: &DemoState) -> InspectorPanel { } } -fn render_inspector(demo: &DemoState) -> SafeHtml { +fn render_inspector(demo: &DemoState) -> Html { // req: html_safety/002 req: view/001 req: component/003 ui::render(&inspector_view(demo)) } -fn architecture_hero() -> SafeHtml { +fn architecture_hero() -> Html { // req: html_safety/002 req: view/001 ui::render(&ArchitectureHero) } -fn architecture_board() -> SafeHtml { +fn architecture_board() -> Html { // req: html_safety/002 req: view/001 ui::render(&ArchitectureBoard) } -fn architecture_inspector() -> SafeHtml { +fn architecture_inspector() -> Html { // req: html_safety/002 req: view/001 ui::render(&ArchitectureInspector) } -fn architecture_activity() -> SafeHtml { +fn architecture_activity() -> Html { // req: html_safety/002 req: view/001 ui::render(&ArchitectureActivity) } -fn render_control_center(page: ControlCenter) -> SafeHtml { +fn render_control_center(page: ControlCenter) -> Html { // req: html_safety/002 req: view/001 req: component/003 ui::render(&page) } @@ -597,11 +661,36 @@ mod tests { .map(|title| title.text().collect::()), Some("slhx Techdemo".to_owned()) ); - assert_eq!(document.select(&selector("script[src=\"/slhx.js\"]")).count(), 1); - assert_eq!(document.select(&selector("link[rel=\"stylesheet\"]")).count(), 2); - assert_eq!(document.select(&selector("link[href=\"/app.css\"]")).count(), 1); - assert_eq!(document.select(&selector("link[href=\"/control_center.css\"]")).count(), 1); - assert_eq!(document.select(&selector("main[data-slhx-root=\"techdemo\"]")).count(), 1); + assert_eq!( + document + .select(&selector("script[src=\"/slhx.js\"]")) + .count(), + 1 + ); + assert_eq!( + document + .select(&selector("link[rel=\"stylesheet\"]")) + .count(), + 2 + ); + assert_eq!( + document + .select(&selector("link[href=\"/app.css\"]")) + .count(), + 1 + ); + assert_eq!( + document + .select(&selector("link[href=\"/control_center.css\"]")) + .count(), + 1 + ); + assert_eq!( + document + .select(&selector("main[data-slhx-root=\"techdemo\"]")) + .count(), + 1 + ); assert!(!html.contains("{+=")); } @@ -615,13 +704,29 @@ mod tests { assert!(!html.contains("__ACTIVITY__")); let document = Html::parse_fragment(html.as_str()); - assert_eq!(document.select(&selector("[data-slhx-root=\"techdemo\"]")).count(), 1); - assert!(document.select(&selector("[data-sid]")).count() >= 7); - assert!(document.select(&selector("[data-hid]")).count() >= 7); - assert_eq!(document.select(&selector(".hero-panel .metrics")).count(), 1); + assert_eq!( + document + .select(&selector("[data-slhx-root=\"techdemo\"]")) + .count(), + 1 + ); + assert_eq!( + document.select(&selector(".hero-panel .metrics")).count(), + 1 + ); assert_eq!(document.select(&selector(".board-card .lanes")).count(), 1); - assert_eq!(document.select(&selector(".glass-card .inspector-hero")).count(), 1); - assert_eq!(document.select(&selector(".glass-card ol.activity")).count(), 1); + assert_eq!( + document + .select(&selector(".glass-card .inspector-hero")) + .count(), + 1 + ); + assert_eq!( + document + .select(&selector(".glass-card ol.activity")) + .count(), + 1 + ); } // req: html_safety/002 req: view/001 req: test/005 @@ -629,7 +734,9 @@ mod tests { fn hero_metrics_are_rendered_by_a_hemplate_view() { let html = render_hero(&DemoState::default()); let document = Html::parse_fragment(html.as_str()); - let metrics = document.select(&selector(".metrics > .metric")).collect::>(); + let metrics = document + .select(&selector(".metrics > .metric")) + .collect::>(); assert_eq!(metrics.len(), 4); assert_eq!( metrics[0] @@ -638,9 +745,10 @@ mod tests { .map(|span| span.text().collect::()), Some("generated resources on this page".to_owned()) ); - assert!(metrics - .iter() - .any(|metric| metric.text().collect::().contains("aggregate impact score"))); + assert!(metrics.iter().any(|metric| metric + .text() + .collect::() + .contains("aggregate impact score"))); } // req: style/001 req: style/002 req: style/003 req: test/005 @@ -652,7 +760,10 @@ mod tests { assert_eq!(classes::is_selected.as_str(), "is-selected"); let document = Html::parse_fragment(board.as_str()); - assert_eq!(document.select(&selector(".lanes > section.lane")).count(), 3); + assert_eq!( + document.select(&selector(".lanes > section.lane")).count(), + 3 + ); let lane = document .select(&selector(r#"section.lane[data-lane="compiler"]"#)) .next() @@ -663,7 +774,10 @@ mod tests { .select(&selector(r#"article.work-card.is-selected[data-key="2"]"#)) .next() .expect("selected work card renders"); - assert_eq!(selected_card.value().attr("class"), Some("work-card is-selected")); + assert_eq!( + selected_card.value().attr("class"), + Some("work-card is-selected") + ); } // req: html_safety/002 req: view/001 req: test/005 @@ -684,7 +798,7 @@ mod tests { assert_eq!(document.select(&selector(".inspector-row")).count(), 3); assert!(document .select(&selector("code")) - .any(|code| code.text().collect::().contains("targets::*"))); + .any(|code| code.text().collect::().contains("control::*"))); } // req: html_safety/002 req: view/001 req: test/005 @@ -698,16 +812,16 @@ mod tests { .expect("live feed row renders"); let text = row.text().collect::(); assert!(text.contains("SSE tick #7")); - assert!(row - .select(&selector("code")) - .any(|code| code.text().collect::() == "slots::live_feed")); + assert!(text.contains("live feed target")); + assert_eq!(row.select(&selector("code")).count(), 0); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn activity_payload_is_rendered_by_a_hemplate_view() { let mut demo = DemoState::default(); - demo.activity.push_back("escaped activity".to_owned()); + demo.activity + .push_back("escaped activity".to_owned()); let html = render_activity(&demo); let document = Html::parse_fragment(html.as_str()); @@ -753,7 +867,9 @@ mod tests { .next() .expect("architecture inspector row renders"); assert_eq!( - row.select(&selector("b")).next().map(|b| b.text().collect::()), + row.select(&selector("b")) + .next() + .map(|b| b.text().collect::()), Some("Page swap".to_owned()) ); assert!(row @@ -786,7 +902,9 @@ mod tests { fn architecture_board_is_rendered_by_a_hemplate_view() { let html = architecture_board(); let document = Html::parse_fragment(html.as_str()); - let lanes = document.select(&selector(".lanes > section.lane")).collect::>(); + let lanes = document + .select(&selector(".lanes > section.lane")) + .collect::>(); assert_eq!(lanes.len(), 3); assert_eq!( lanes[0] diff --git a/examples/techdemo/templates/control_center.heml b/examples/techdemo/templates/control_center.heml index 8279a62..445d408 100644 --- a/examples/techdemo/templates/control_center.heml +++ b/examples/techdemo/templates/control_center.heml @@ -17,7 +17,7 @@