feat(techdemo): render issues with hemplate partials

req: examples/001

req: dx/008

req: list/003

req: form/002
This commit is contained in:
slhx agent
2026-05-11 07:53:51 +02:00
parent 5a29161148
commit ad8806c187
12 changed files with 260 additions and 42 deletions
+171 -38
View File
@@ -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<WorkItem>,
activity: VecDeque<String>,
spotlight: String,
selected_id: Option<u64>,
}
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<DemoState>,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct IssueLane {
class: String,
lane_id: &'static str,
move_handle: u32,
title: &'static str,
description: &'static str,
cards: Vec<IssueCard>,
}
#[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<Shared>) -> 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<Shared>) -> 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<Shared>) -> 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<Shared>) -> HandlerRegistry {
// req: examples/001
let mut demo = shared.demo.lock().unwrap();
if let Some(id) = form.value("work_id").and_then(|value| value.parse::<u64>().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 {
<title>slhx Techdemo</title>
<script src="/slhx.js" defer></script>
<script>
function slhxTechdemoLaunch(button) {{
const form = button.closest('form');
const root = button.closest('[data-slhx-root]');
const data = new URLSearchParams(new FormData(form));
data.set('__h', button.getAttribute('data-hid'));
function slhxTechdemoEffect(root, data) {{
fetch('/', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Accept': 'application/slhx' }},
@@ -305,6 +375,36 @@ fn shell(body: String) -> String {
}})
.then((response) => response.arrayBuffer())
.then((buffer) => window.slhx.applyBatch(buffer, root));
}}
function slhxTechdemoLaunch(button) {{
const form = button.closest('form');
const data = new URLSearchParams(new FormData(form));
data.set('__h', button.getAttribute('data-hid'));
slhxTechdemoEffect(button.closest('[data-slhx-root]'), data);
return false;
}}
function slhxTechdemoDrag(event) {{
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', event.currentTarget.getAttribute('data-key'));
}}
function slhxTechdemoDragOver(event) {{
event.preventDefault();
event.currentTarget.classList.add('drop-ready');
}}
function slhxTechdemoDragLeave(event) {{
event.currentTarget.classList.remove('drop-ready');
}}
function slhxTechdemoDrop(event) {{
event.preventDefault();
const lane = event.currentTarget;
lane.classList.remove('drop-ready');
const id = event.dataTransfer.getData('text/plain');
if (!id) return false;
const data = new URLSearchParams();
data.set('__h', lane.getAttribute('data-move-hid'));
data.set('work_id', id);
data.set('lane', lane.getAttribute('data-lane'));
slhxTechdemoEffect(lane.closest('[data-slhx-root]'), data);
return false;
}}
</script>
@@ -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::<Vec<_>>();
let mut out = String::from("<div class=\"lanes\">");
for (idx, (_, title, description)) in LANES.iter().enumerate() {
out.push_str(&format!(r#"<section class="lane"><h3>{}</h3><p>{}</p>"#, 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("</section>");
for lane in lanes {
out.push_str(&render_template(&lane));
}
out.push_str("</div>");
out
}
fn render_card(item: &WorkItem) -> String {
format!(
r#"<article class="work-card" data-key="{id}">
<header><strong>{title}</strong><span class="pill">{stage}</span></header>
<div class="impact"><i style="width:{impact_percent}%"></i></div>
<div class="card-actions">
<button type="button" data-hid="{spotlight}" data-work-id="{id}">Inspect</button>
<button type="button" data-hid="{advance}" data-work-id="{id}">Advance</button>
<button type="button" data-hid="{delete}" data-work-id="{id}">Delete</button>
</div>
</article>"#,
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#"<div class="inspector-row"><b>Selected fact</b><br>{}</div>
<div class="inspector-row"><b>Wire contract</b><br><code>POST __h → application/slhx → EffectBatch</code></div>
<div class="inspector-row"><b>Runtime</b><br>Root-scoped delegated listeners; numeric targets only.</div>"#,
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 {
"<ol class=\"activity\"><li>Clicked a real anchor</li><li>Fetched HTML with X-SLHX-Partial</li><li>Preserved native fallback semantics</li></ol>".into()
}
fn render_template(template: &impl Hemplate) -> String {
template.render().expect("techdemo hemplate partial renders")
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")