diff --git a/Cargo.lock b/Cargo.lock index 176a19f..f2ad342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -608,6 +608,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "slhx-techdemo" +version = "0.1.0" +dependencies = [ + "axum", + "futures-util", + "slhx", + "slhx-axum", + "slhx-build", + "slhx-test", + "tokio", +] + [[package]] name = "slhx-test" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 220c405..c61e848 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test", "examples/v0", "examples/kanban"] +members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test", "examples/v0", "examples/kanban", "examples/techdemo"] [workspace.package] version = "0.1.0" diff --git a/examples/techdemo/Cargo.toml b/examples/techdemo/Cargo.toml new file mode 100644 index 0000000..94100af --- /dev/null +++ b/examples/techdemo/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "slhx-techdemo" +version.workspace = true +edition.workspace = true +publish = false + +[lib] +path = "src/lib.rs" + +[dependencies] +axum = "0.7" +futures-util = "0.3" +slhx = { path = "../../slhx" } +slhx-axum = { path = "../../slhx-axum" } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } + +[dev-dependencies] +slhx-test = { path = "../../slhx-test" } + +[build-dependencies] +slhx-build = { path = "../../slhx-build" } diff --git a/examples/techdemo/README.md b/examples/techdemo/README.md new file mode 100644 index 0000000..65d1020 --- /dev/null +++ b/examples/techdemo/README.md @@ -0,0 +1,19 @@ +# slhx full techdemo + +Run: + + cargo run -p slhx-techdemo + +Open . + +This demo is tailored to showcase slhx strengths: + +- modern SSR-first UI +- generated resource modules from `.heml` +- native form posts carrying `__h` numeric handle ids +- multi-target `application/slhx` EffectBatch responses +- generated slot updates instead of selectors +- root-scoped runtime lowering (`data-hid`, `data-sid`) +- page-enhancer navigation with native link fallback +- SSE server push into a generated slot +- no user-authored browser JavaScript diff --git a/examples/techdemo/build.rs b/examples/techdemo/build.rs new file mode 100644 index 0000000..5f2e967 --- /dev/null +++ b/examples/techdemo/build.rs @@ -0,0 +1,3 @@ +fn main() { + slhx_build::app().run().unwrap(); +} diff --git a/examples/techdemo/src/lib.rs b/examples/techdemo/src/lib.rs new file mode 100644 index 0000000..0e557cb --- /dev/null +++ b/examples/techdemo/src/lib.rs @@ -0,0 +1,34 @@ +#[slhx::surface] +pub mod ui {} + +#[cfg(test)] +mod tests { + use super::ui; + use slhx::{IntoEffect, SafeHtml}; + use slhx_test::inspect; + + // req: examples/001 req: codegen/002 req: public_api/001 + #[test] + fn techdemo_uses_generated_slots_for_multi_target_updates() { + fn update() -> impl IntoEffect { + ( + ui::control_center::slots::hero_metrics.html(SafeHtml::trusted("fast")), + ui::control_center::slots::notice.text("typed"), + ) + } + + let batch = inspect(update()); + assert!(batch.has_slot(ui::control_center::slots::hero_metrics)); + assert!(batch.has_slot(ui::control_center::slots::notice)); + } + + // req: examples/001 req: form/002 req: codegen/003 + #[test] + fn techdemo_exports_form_and_interaction_handles() { + assert_ne!(ui::control_center::handles::launch_work.id().id, ui::control_center::handles::advance_work.id().id); + assert_eq!( + ui::control_center::forms::launch_work.field("title").resource, + ui::control_center::forms::launch_work.id() + ); + } +} diff --git a/examples/techdemo/src/main.rs b/examples/techdemo/src/main.rs new file mode 100644 index 0000000..c11c031 --- /dev/null +++ b/examples/techdemo/src/main.rs @@ -0,0 +1,449 @@ +use axum::extract::{Query, State}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::Router; +use futures_util::{stream, StreamExt}; +use slhx::{IntoEffect, SafeHtml}; +use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest}; +use slhx_techdemo::ui; +use std::collections::{BTreeMap, VecDeque}; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +const LANES: [(&str, &str, &str); 3] = [ + ("compiler", "Compiler", "Surface → generated API"), + ("runtime", "Runtime", "EffectBatch → DOM"), + ("product", "Product", "Native UX, zero app JS"), +]; + +#[derive(Clone)] +struct WorkItem { + id: u64, + title: String, + lane: usize, + impact: u8, + stage: Stage, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum Stage { + Draft, + Active, + Shipped, +} + +impl Stage { + fn advance(self) -> Self { + match self { + Self::Draft => Self::Active, + Self::Active => Self::Shipped, + Self::Shipped => Self::Shipped, + } + } + + fn label(self) -> &'static str { + match self { + Self::Draft => "Draft", + Self::Active => "Active", + Self::Shipped => "Shipped", + } + } +} + +#[derive(Clone)] +struct DemoState { + next_id: u64, + work: Vec, + activity: VecDeque, + spotlight: String, +} + +impl Default for DemoState { + fn default() -> Self { + 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 }, + ], + activity: VecDeque::new(), + spotlight: "No selectors. Generated resources address every target.".into(), + }; + state.log("Demo booted from server-rendered HTML"); + state.log("Runtime attached one delegated listener per root"); + state + } +} + +impl DemoState { + fn log(&mut self, message: impl Into) { + self.activity.push_front(message.into()); + while self.activity.len() > 6 { + self.activity.pop_back(); + } + } +} + +struct Shared { + demo: Mutex, +} + +#[tokio::main] +async fn main() { + 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("/slhx.js", get(runtime)) + .with_state(state); + + let addr = SocketAddr::from(([127, 0, 0, 1], 3002)); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + println!("slhx techdemo: http://{addr}"); + axum::serve(listener, app).await.unwrap(); +} + +// req: examples/001 req: dx/008 req: public_api/001 +async fn home(State(state): State>, request: PageRequest) -> impl IntoResponse { + let demo = state.demo.lock().unwrap().clone(); + request + .page(page_html(&demo), shell) + .title("slhx Techdemo") + .fingerprint(ui::BUILD_FINGERPRINT) +} + +// req: page_swap/001 req: page_swap/002 req: examples/001 +async fn architecture(request: PageRequest) -> impl IntoResponse { + let body = ui::control_center::lower_html(include_str!("../templates/control_center.heml")) + .replace("__HERO__", &architecture_hero()) + .replace("__BOARD__", &architecture_board()) + .replace("__INSPECTOR__", &architecture_inspector()) + .replace("__ACTIVITY__", &architecture_activity()); + request + .page(body, shell) + .title("slhx Architecture") + .fingerprint(ui::BUILD_FINGERPRINT) +} + +async fn runtime() -> impl IntoResponse { + runtime_js() +} + +async fn interact( + State(state): State>, + form: InteractionForm, +) -> Result { + registry(state).dispatch(form) +} + +// req: push/001 req: push/003 req: examples/001 +async fn events(Query(params): Query>) -> impl IntoResponse { + if params.contains_key("once") { + let effect = ui::control_center::slots::live_feed.html(SafeHtml::trusted(live_feed(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 = ui::control_center::slots::live_feed.html(SafeHtml::trusted(live_feed(tick))); + Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), tick + 1)) + }) + .boxed(); + sse(batches) +} + +fn registry(shared: Arc) -> HandlerRegistry { + HandlerRegistry::new(ui::BUILD_FINGERPRINT) + .register(ui::control_center::handles::launch_work.id().id, { + let shared = shared.clone(); + move |form| { + // req: form/002 req: examples/001 + let mut demo = shared.demo.lock().unwrap(); + let title = form.value("title").unwrap_or("").trim(); + let lane = parse_lane(form.value("lane")); + let impact = form.value("impact").and_then(|value| value.parse::().ok()).unwrap_or(5).clamp(1, 9); + if !title.is_empty() { + let id = demo.next_id; + demo.next_id += 1; + demo.work.push(WorkItem { id, title: title.into(), lane, impact, stage: Stage::Draft }); + demo.spotlight = format!("Form data became typed Rust state; card #{id} was rendered by a generated slot."); + demo.log(format!("Launched card #{id}: {title}")); + } + demo_effects(&demo, "Launch accepted · 4 targets updated") + } + }) + .register(ui::control_center::handles::advance_work.id().id, { + let shared = shared.clone(); + move |form| { + // req: list/003 req: examples/001 + let mut demo = shared.demo.lock().unwrap(); + let title = update_work(&mut demo, form.value("work_id"), |item| { + item.stage = item.stage.advance(); + if item.stage == Stage::Shipped { + item.lane = LANES.len() - 1; + } + }); + if let Some(title) = title { + demo.spotlight = format!("{title} advanced without a selector: the server returned slot effects."); + demo.log(format!("Advanced {title}")); + } + demo_effects(&demo, "Pipeline advanced") + } + }) + .register(ui::control_center::handles::delete_work.id().id, { + let shared = shared.clone(); + move |form| { + // req: list/003 req: examples/001 + let mut demo = shared.demo.lock().unwrap(); + if let Some(id) = form.value("work_id").and_then(|value| value.parse::().ok()) { + let before = demo.work.len(); + demo.work.retain(|item| item.id != id); + if demo.work.len() < before { + demo.spotlight = format!("Card #{id} removed; the board, metrics, activity, and inspector updated together."); + demo.log(format!("Deleted card #{id}")); + } + } + demo_effects(&demo, "Card removed") + } + }) + .register(ui::control_center::handles::spotlight_work.id().id, { + let shared = shared.clone(); + move |form| { + // req: examples/001 + let mut demo = shared.demo.lock().unwrap(); + if let Some(id) = form.value("work_id").and_then(|value| value.parse::().ok()) { + if let Some(item) = demo.work.iter().find(|item| item.id == id) { + demo.spotlight = format!("{} · lane={} · stage={} · impact={}", item.title, LANES[item.lane].1, item.stage.label(), item.impact); + demo.log(format!("Inspected card #{id}")); + } + } + demo_effects(&demo, "Inspector focused") + } + }) + .register(ui::control_center::handles::simulate_push.id().id, { + 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 EffectBatch shape"); + ( + ui::control_center::slots::live_feed.html(SafeHtml::trusted(live_feed(demo.activity.len() as u64))), + ui::control_center::slots::activity.html(SafeHtml::trusted(render_activity(&demo))), + ui::control_center::slots::notice.text("Push simulated · no client app code"), + ) + } + }) + .register(ui::control_center::handles::reset_demo.id().id, { + let shared = shared.clone(); + move |_| { + // req: examples/001 + let mut demo = shared.demo.lock().unwrap(); + *demo = DemoState::default(); + demo_effects(&demo, "Demo reset from Rust state") + } + }) +} + +fn update_work(demo: &mut DemoState, id: Option<&str>, update: impl FnOnce(&mut WorkItem)) -> Option { + let id = id.and_then(|value| value.parse::().ok())?; + let item = demo.work.iter_mut().find(|item| item.id == id)?; + let title = item.title.clone(); + update(item); + Some(title) +} + +fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect { + ( + ui::control_center::slots::hero_metrics.html(SafeHtml::trusted(render_hero(demo))), + ui::control_center::slots::board.html(SafeHtml::trusted(render_board(demo))), + ui::control_center::slots::activity.html(SafeHtml::trusted(render_activity(demo))), + ui::control_center::slots::inspector.html(SafeHtml::trusted(render_inspector(demo))), + ui::control_center::slots::notice.text(notice), + ui::control_center::forms::launch_work.clear("title"), + ) +} + +fn parse_lane(value: Option<&str>) -> usize { + let value = value.unwrap_or(LANES[0].0); + LANES.iter().position(|(id, _, _)| *id == value).unwrap_or(0) +} + +fn page_html(demo: &DemoState) -> String { + ui::control_center::lower_html(include_str!("../templates/control_center.heml")) + .replace("__HERO__", &render_hero(demo)) + .replace("__BOARD__", &render_board(demo)) + .replace("__INSPECTOR__", &render_inspector(demo)) + .replace("__ACTIVITY__", &render_activity(demo)) +} + +fn shell(body: String) -> String { + format!( + r#" + + + + + slhx Techdemo + + + +{body} +"# + ) +} + +fn render_hero(demo: &DemoState) -> String { + 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(); + format!( + r#"
+
{}generated resources on this page
+
{}active typed work items
+
{}shipped without app JS
+
{}aggregate impact score
+
"#, + 13, + active, + shipped, + impact, + ) +} + +fn render_board(demo: &DemoState) -> String { + let mut out = String::from("
"); + for (idx, (_, title, description)) in LANES.iter().enumerate() { + out.push_str(&format!(r#"

{}

{}

"#, escape_html(title), escape_html(description))); + for item in demo.work.iter().filter(|item| item.lane == idx) { + out.push_str(&render_card(item)); + } + out.push_str("
"); + } + out.push_str("
"); + out +} + +fn render_card(item: &WorkItem) -> String { + format!( + r#"
+
{title}{stage}
+
+
+ + + +
+
"#, + id = item.id, + title = escape_html(&item.title), + stage = item.stage.label(), + impact_percent = item.impact as usize * 11, + spotlight = ui::control_center::handles::spotlight_work.id().id, + advance = ui::control_center::handles::advance_work.id().id, + delete = ui::control_center::handles::delete_work.id().id, + ) +} + +fn render_activity(demo: &DemoState) -> String { + let mut out = String::from("
    "); + for item in &demo.activity { + out.push_str(&format!("
  1. {}
  2. ", escape_html(item))); + } + out.push_str("
"); + out +} + +fn render_inspector(demo: &DemoState) -> String { + format!( + r#"
Selected fact
{}
+
Wire contract
POST __h → application/slhx → EffectBatch
+
Runtime
Root-scoped delegated listeners; numeric targets only.
"#, + escape_html(&demo.spotlight), + ) +} + +fn live_feed(tick: u64) -> String { + format!( + r#"
SSE tick #{tick}
Server streamed a typed EffectBatch into slots::live_feed.
"# + ) +} + +fn architecture_hero() -> String { + "
1template source of truth
0CSS selectors in handlers
typed composition through tuples
".into() +} + +fn architecture_board() -> String { + "

hemplate

Owns syntax and Surface facts.

slhx-build

Generates resources and lowering tables.

runtime

Executes compact EffectBatch ops.

".into() +} + +fn architecture_inspector() -> String { + "
Page swap
This route was fetched as a partial and rendered through the same root.
".into() +} + +fn architecture_activity() -> String { + "
  1. Clicked a real anchor
  2. Fetched HTML with X-SLHX-Partial
  3. Preserved native fallback semantics
".into() +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/examples/techdemo/templates/control_center.heml b/examples/techdemo/templates/control_center.heml new file mode 100644 index 0000000..4ac280c --- /dev/null +++ b/examples/techdemo/templates/control_center.heml @@ -0,0 +1,65 @@ +
+
+
+

Semantic, Laterally HX

+

Rust owns the interaction graph. The browser executes tiny typed effects.

+

This live demo shows symbolic templates lowered to numeric ids, native forms, multi-target EffectBatches, page swapping, and server push — no app JavaScript.

+
+
__HERO__
+
+ + + +
+ + +
+
+ Generated slots + keyed cards + no selectors, no VDOM +
+
__BOARD__
+
+
+ +
+
+

Effect inspector

+
__INSPECTOR__
+
+
+

Activity stream

+
__ACTIVITY__
+
+
+

Server push

+
Waiting for SSE heartbeat…
+
+
+ + + + +