From ad8806c187ce186288be90a0d47829bed200a147 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Mon, 11 May 2026 07:53:51 +0200 Subject: [PATCH] feat(techdemo): render issues with hemplate partials req: examples/001 req: dx/008 req: list/003 req: form/002 --- Cargo.lock | 19 ++ examples/techdemo/Cargo.toml | 1 + examples/techdemo/README.md | 3 +- examples/techdemo/src/main.rs | 209 ++++++++++++++---- .../techdemo/templates/control_center.heml | 1 + .../templates/partials/inspector_empty.heml | 3 + .../templates/partials/inspector_panel.heml | 4 + .../partials/inspector_selected.heml | 5 + .../templates/partials/issue_card.heml | 9 + .../templates/partials/issue_lane.heml | 7 + examples/techdemo/tests/browser_e2e.rs | 30 ++- examples/techdemo/tests/e2e.rs | 11 +- 12 files changed, 260 insertions(+), 42 deletions(-) create mode 100644 examples/techdemo/templates/partials/inspector_empty.heml create mode 100644 examples/techdemo/templates/partials/inspector_panel.heml create mode 100644 examples/techdemo/templates/partials/inspector_selected.heml create mode 100644 examples/techdemo/templates/partials/issue_card.heml create mode 100644 examples/techdemo/templates/partials/issue_lane.heml diff --git a/Cargo.lock b/Cargo.lock index 0ac4174..1cd4b2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -389,6 +389,14 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hemplate" +version = "0.1.0" +dependencies = [ + "hemplate-core", + "hemplate-derive", +] + [[package]] name = "hemplate-core" version = "0.1.0" @@ -399,6 +407,16 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "hemplate-derive" +version = "0.1.0" +dependencies = [ + "hemplate-core", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "hemplate-parser" version = "0.1.0" @@ -1404,6 +1422,7 @@ version = "0.1.0" dependencies = [ "axum", "futures-util", + "hemplate", "scraper", "slhx", "slhx-axum", diff --git a/examples/techdemo/Cargo.toml b/examples/techdemo/Cargo.toml index 41e4fb9..8352cbc 100644 --- a/examples/techdemo/Cargo.toml +++ b/examples/techdemo/Cargo.toml @@ -10,6 +10,7 @@ path = "src/lib.rs" [dependencies] axum = "0.7" futures-util = "0.3" +hemplate = { path = "../../../hemplate/hemplate" } slhx = { path = "../../slhx" } slhx-axum = { path = "../../slhx-axum" } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } diff --git a/examples/techdemo/README.md b/examples/techdemo/README.md index 18b60ec..4e1c86d 100644 --- a/examples/techdemo/README.md +++ b/examples/techdemo/README.md @@ -10,13 +10,14 @@ This is a polished Linear-style product demo for planning typed work across lane - modern SSR-first UI - generated resource modules from `.heml` +- hemplate partials for issue lanes, cards, and inspector panels - 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 -- one tiny launch glue function plus slhx runtime-driven interactions +- drag-and-drop lane moves persisted by typed server handlers Verification: diff --git a/examples/techdemo/src/main.rs b/examples/techdemo/src/main.rs index 0aad8e6..753027e 100644 --- a/examples/techdemo/src/main.rs +++ b/examples/techdemo/src/main.rs @@ -3,6 +3,7 @@ use axum::response::IntoResponse; use axum::routing::get; use axum::Router; use futures_util::{stream, StreamExt}; +use hemplate::Hemplate; use slhx::{IntoEffect, SafeHtml}; use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest}; use slhx_techdemo::ui; @@ -58,6 +59,7 @@ struct DemoState { work: Vec, activity: VecDeque, spotlight: String, + selected_id: Option, } impl Default for DemoState { @@ -71,6 +73,7 @@ impl Default for DemoState { ], activity: VecDeque::new(), spotlight: "No selectors. Generated resources address every target.".into(), + selected_id: Some(2), }; state.log("Demo booted from server-rendered HTML"); state.log("Runtime attached one delegated listener per root"); @@ -91,6 +94,50 @@ struct Shared { demo: Mutex, } +#[derive(Hemplate)] +#[hemplate = "partials"] +struct IssueLane { + class: String, + lane_id: &'static str, + move_handle: u32, + title: &'static str, + description: &'static str, + cards: Vec, +} + +#[derive(Hemplate)] +#[hemplate = "partials"] +struct IssueCard { + class: String, + id: u64, + title: String, + stage: &'static str, + impact_style: String, + spotlight_handle: u32, + advance_handle: u32, + delete_handle: u32, +} + +#[derive(Hemplate)] +#[hemplate = "partials"] +struct InspectorPanel { + selected: String, + spotlight: String, +} + +#[derive(Hemplate)] +#[hemplate = "partials"] +struct InspectorSelected { + title: String, + lane: &'static str, + stage: &'static str, + impact: u8, +} + +#[derive(Hemplate)] +#[hemplate = "partials"] +struct InspectorEmpty; + #[tokio::main] async fn main() { let state = Arc::new(Shared { demo: Mutex::new(DemoState::default()) }); @@ -173,6 +220,7 @@ fn registry(shared: Arc) -> HandlerRegistry { let id = demo.next_id; demo.next_id += 1; demo.work.push(WorkItem { id, title: title.into(), lane, impact, stage: Stage::Draft }); + demo.selected_id = Some(id); demo.spotlight = format!("Form data became typed Rust state; card #{id} was rendered by a generated slot."); demo.log(format!("Launched card #{id}: {title}")); } @@ -197,6 +245,28 @@ fn registry(shared: Arc) -> HandlerRegistry { demo_effects(&demo, "Pipeline advanced") } }) + .register(ui::control_center::handles::move_to_lane.id().id, { + let shared = shared.clone(); + move |form| { + // req: list/003 req: examples/001 + let mut demo = shared.demo.lock().unwrap(); + let lane = parse_lane(form.value("lane")); + let title = update_work(&mut demo, form.value("work_id"), |item| { + item.lane = lane; + item.stage = match lane { + 0 => Stage::Draft, + 1 => Stage::Active, + _ => Stage::Shipped, + }; + }); + if let Some(title) = title { + demo.selected_id = form.value("work_id").and_then(|value| value.parse().ok()); + demo.spotlight = format!("{title} moved to {} by drag-and-drop; Rust re-rendered the board slot.", LANES[lane].1); + demo.log(format!("Dragged {title} to {}", LANES[lane].1)); + } + demo_effects(&demo, "Drag-and-drop move persisted") + } + }) .register(ui::control_center::handles::delete_work.id().id, { let shared = shared.clone(); move |form| { @@ -206,6 +276,9 @@ fn registry(shared: Arc) -> HandlerRegistry { let before = demo.work.len(); demo.work.retain(|item| item.id != id); if demo.work.len() < before { + if demo.selected_id == Some(id) { + demo.selected_id = demo.work.first().map(|item| item.id); + } demo.spotlight = format!("Card #{id} removed; the board, metrics, activity, and inspector updated together."); demo.log(format!("Deleted card #{id}")); } @@ -219,7 +292,8 @@ fn registry(shared: Arc) -> HandlerRegistry { // 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) { + if let Some(item) = demo.work.iter().find(|item| item.id == id).cloned() { + demo.selected_id = Some(id); demo.spotlight = format!("{} · lane={} · stage={} · impact={}", item.title, LANES[item.lane].1, item.stage.label(), item.impact); demo.log(format!("Inspected card #{id}")); } @@ -293,11 +367,7 @@ fn shell(body: String) -> String { slhx Techdemo @@ -346,7 +446,10 @@ fn shell(body: String) -> String { .lane {{ min-height:330px; border:1px solid var(--line); border-radius:24px; padding:14px; background:linear-gradient(180deg, rgba(0,0,0,.26), rgba(255,255,255,.035)); overflow:hidden; }} .lane h3 {{ margin:0 0 4px; }} .lane p {{ color:var(--muted); margin:0 0 12px; font-size:13px; }} - .work-card {{ border:1px solid rgba(255,255,255,.18); border-radius:20px; margin:12px 0; padding:14px; background:linear-gradient(145deg, rgba(255,255,255,.15), rgba(255,255,255,.055)); box-shadow:0 14px 38px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.1); overflow:hidden; overflow-wrap:anywhere; }} + .work-card {{ border:1px solid rgba(255,255,255,.18); border-radius:20px; margin:12px 0; padding:14px; background:linear-gradient(145deg, rgba(255,255,255,.15), rgba(255,255,255,.055)); box-shadow:0 14px 38px rgba(0,0,0,.22), inset 0 1px 0 rgba(255,255,255,.1); overflow:hidden; overflow-wrap:anywhere; cursor:grab; }} + .work-card:active {{ cursor:grabbing; }} + .work-card.is-selected {{ border-color:rgba(68,231,255,.9); box-shadow:0 0 0 1px rgba(68,231,255,.4), 0 22px 55px rgba(68,231,255,.14); }} + .lane.drop-ready {{ border-color:rgba(184,255,90,.85); background:linear-gradient(180deg, rgba(184,255,90,.11), rgba(255,255,255,.04)); }} .work-card header {{ display:flex; justify-content:space-between; gap:10px; align-items:start; }} .pill {{ display:inline-flex; border:1px solid var(--line); border-radius:999px; padding:4px 9px; font-size:12px; color:var(--lime); }} .impact {{ height:7px; border-radius:999px; background:rgba(255,255,255,.12); overflow:hidden; margin:12px 0; }} @@ -358,6 +461,10 @@ fn shell(body: String) -> String { .glow {{ box-shadow:0 0 0 1px rgba(184,255,90,.12), 0 24px 90px rgba(184,255,90,.08); }} .activity {{ display:grid; gap:10px; padding:0; margin:0; list-style:none; }} .activity li, .inspector-row, .live-row {{ border:1px solid var(--line); border-radius:16px; padding:12px; background:rgba(0,0,0,.18); color:var(--muted); overflow-wrap:anywhere; }} + .inspector-hero {{ border:1px solid rgba(68,231,255,.35); border-radius:20px; padding:16px; margin-bottom:12px; background:linear-gradient(135deg, rgba(68,231,255,.15), rgba(255,79,216,.1)); }} + .inspector-hero span {{ display:block; color:var(--cyan); font-size:12px; text-transform:uppercase; letter-spacing:.14em; font-weight:900; }} + .inspector-hero strong {{ display:block; font-size:22px; letter-spacing:-.035em; margin-top:8px; overflow-wrap:anywhere; }} + .inspector-hero em {{ display:block; color:var(--muted); font-style:normal; margin-top:6px; }} .inspector-row b {{ color:var(--text); }} .live-row strong {{ color:var(--lime); }} code {{ color:var(--cyan); }} @@ -388,37 +495,47 @@ fn render_hero(demo: &DemoState) -> String { } fn render_board(demo: &DemoState) -> String { + let lanes = LANES + .iter() + .enumerate() + .map(|(idx, (lane_id, title, description))| IssueLane { + class: String::from("lane"), + lane_id, + move_handle: ui::control_center::handles::move_to_lane.id().id, + title, + description, + cards: demo + .work + .iter() + .filter(|item| item.lane == idx) + .map(|item| issue_card(item, demo.selected_id == Some(item.id))) + .collect(), + }) + .collect::>(); + 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("
"); + for lane in lanes { + out.push_str(&render_template(&lane)); } 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 issue_card(item: &WorkItem, selected: bool) -> IssueCard { + IssueCard { + class: if selected { + String::from("work-card is-selected") + } else { + String::from("work-card") + }, + id: item.id, + title: item.title.clone(), + stage: item.stage.label(), + impact_style: format!("width:{}%", item.impact as usize * 11), + spotlight_handle: ui::control_center::handles::spotlight_work.id().id, + advance_handle: ui::control_center::handles::advance_work.id().id, + delete_handle: ui::control_center::handles::delete_work.id().id, + } } fn render_activity(demo: &DemoState) -> String { @@ -431,12 +548,24 @@ fn render_activity(demo: &DemoState) -> String { } 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), - ) + let selected = demo + .selected_id + .and_then(|id| demo.work.iter().find(|item| item.id == id)); + let selected = selected.map_or_else( + || render_template(&InspectorEmpty), + |item| { + render_template(&InspectorSelected { + title: item.title.clone(), + lane: LANES[item.lane].1, + stage: item.stage.label(), + impact: item.impact, + }) + }, + ); + render_template(&InspectorPanel { + selected, + spotlight: demo.spotlight.clone(), + }) } fn live_feed(tick: u64) -> String { @@ -461,6 +590,10 @@ fn architecture_activity() -> String { "
  1. Clicked a real anchor
  2. Fetched HTML with X-SLHX-Partial
  3. Preserved native fallback semantics
".into() } +fn render_template(template: &impl Hemplate) -> String { + template.render().expect("techdemo hemplate partial renders") +} + fn escape_html(value: &str) -> String { value .replace('&', "&") diff --git a/examples/techdemo/templates/control_center.heml b/examples/techdemo/templates/control_center.heml index 9bd6214..7f0bfa8 100644 --- a/examples/techdemo/templates/control_center.heml +++ b/examples/techdemo/templates/control_center.heml @@ -63,4 +63,5 @@ + diff --git a/examples/techdemo/templates/partials/inspector_empty.heml b/examples/techdemo/templates/partials/inspector_empty.heml new file mode 100644 index 0000000..8672ad1 --- /dev/null +++ b/examples/techdemo/templates/partials/inspector_empty.heml @@ -0,0 +1,3 @@ +
+ No issue selected +
diff --git a/examples/techdemo/templates/partials/inspector_panel.heml b/examples/techdemo/templates/partials/inspector_panel.heml new file mode 100644 index 0000000..9455f23 --- /dev/null +++ b/examples/techdemo/templates/partials/inspector_panel.heml @@ -0,0 +1,4 @@ +{+= self.selected =+} +
What happened
{+ self.spotlight +}
+
Wire contract
POST __h → application/slhx → EffectBatch
+
Runtime
Root-scoped delegated listeners; numeric targets only.
diff --git a/examples/techdemo/templates/partials/inspector_selected.heml b/examples/techdemo/templates/partials/inspector_selected.heml new file mode 100644 index 0000000..c10611f --- /dev/null +++ b/examples/techdemo/templates/partials/inspector_selected.heml @@ -0,0 +1,5 @@ +
+ Selected issue + {+ self.title +} + {+ self.lane +} · {+ self.stage +} · impact {+ self.impact +} +
diff --git a/examples/techdemo/templates/partials/issue_card.heml b/examples/techdemo/templates/partials/issue_card.heml new file mode 100644 index 0000000..ec2edb1 --- /dev/null +++ b/examples/techdemo/templates/partials/issue_card.heml @@ -0,0 +1,9 @@ +
+
{+ self.title +}{+ self.stage +}
+
+
+ + + +
+
diff --git a/examples/techdemo/templates/partials/issue_lane.heml b/examples/techdemo/templates/partials/issue_lane.heml new file mode 100644 index 0000000..0203320 --- /dev/null +++ b/examples/techdemo/templates/partials/issue_lane.heml @@ -0,0 +1,7 @@ +
+

{+ self.title +}

+

{+ self.description +}

+ +
diff --git a/examples/techdemo/tests/browser_e2e.rs b/examples/techdemo/tests/browser_e2e.rs index e6ae064..b09c564 100644 --- a/examples/techdemo/tests/browser_e2e.rs +++ b/examples/techdemo/tests/browser_e2e.rs @@ -81,6 +81,10 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> { wait_for_text(&driver, "body", "Browser verified issue").await?; assert_text(&driver, "width:88%").await?; + drag_card_to_lane(&driver, 4, "runtime").await?; + wait_for_text(&driver, ".lane[data-lane='runtime'] .work-card[data-key='4']", "Active").await?; + wait_for_text(&driver, &slot_selector(ui::control_center::slots::notice.id().id), "Drag-and-drop move persisted").await?; + driver .find(By::Css(&card_button_selector(ui::control_center::handles::spotlight_work.id().id, 4))) .await? @@ -89,7 +93,7 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> { wait_for_text( &driver, &slot_selector(ui::control_center::slots::inspector.id().id), - "Browser verified issue · lane=Product · stage=Draft · impact=8", + "Browser verified issue · lane=Runtime · stage=Active · impact=8", ) .await?; @@ -98,7 +102,7 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> { .await? .click() .await?; - wait_for_text(&driver, ".work-card[data-key='4']", "Active").await?; + wait_for_text(&driver, ".lane[data-lane='product'] .work-card[data-key='4']", "Shipped").await?; driver .find(By::Css(&handle_selector(ui::control_center::handles::simulate_push.id().id))) @@ -145,6 +149,28 @@ fn card_button_selector(handle_id: u32, work_id: u64) -> String { format!(r#"[data-hid="{handle_id}"][data-work-id="{work_id}"]"#) } +async fn drag_card_to_lane(driver: &WebDriver, work_id: u64, lane: &str) -> WebDriverResult<()> { + driver + .execute( + &format!( + r#" + const card = document.querySelector({:?}); + const lane = document.querySelector({:?}); + const data = new DataTransfer(); + card.dispatchEvent(new DragEvent('dragstart', {{ bubbles: true, dataTransfer: data }})); + lane.dispatchEvent(new DragEvent('dragover', {{ bubbles: true, cancelable: true, dataTransfer: data }})); + lane.dispatchEvent(new DragEvent('drop', {{ bubbles: true, cancelable: true, dataTransfer: data }})); + return true; + "#, + format!(".work-card[data-key='{work_id}']"), + format!(".lane[data-lane='{lane}']"), + ), + Vec::new(), + ) + .await?; + Ok(()) +} + async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> { let deadline = Instant::now() + Duration::from_secs(8); loop { diff --git a/examples/techdemo/tests/e2e.rs b/examples/techdemo/tests/e2e.rs index 1d99ec0..9881cdf 100644 --- a/examples/techdemo/tests/e2e.rs +++ b/examples/techdemo/tests/e2e.rs @@ -101,10 +101,19 @@ fn product_is_e2e_working_over_http() { assert_payload_not_contains(&missing_title_batch, "data-key=\"8\""); assert_payload_not_contains(&missing_title_batch, "MUTATED"); + let move_to_lane = post( + "/", + &format!("__h={}&work_id=4&lane=runtime", ui::control_center::handles::move_to_lane.id().id), + ); + assert_effect_response(&move_to_lane); + let move_to_lane_batch = move_to_lane.batch(); + assert_card(&move_to_lane_batch, "Design hero moment", "Runtime", "Active", "width:99%"); + assert_payload_contains(&move_to_lane_batch, "Drag-and-drop move persisted"); + let inspect = post("/", "__h=3112123592&work_id=4"); assert_effect_response(&inspect); let inspect_batch = inspect.batch(); - assert_payload_contains(&inspect_batch, "Design hero moment · lane=Product"); + assert_payload_contains(&inspect_batch, "Design hero moment · lane=Runtime"); assert_payload_contains(&inspect_batch, "Inspector focused"); let advance = post("/", "__h=4085043678&work_id=4");