feat(techdemo): render issues with hemplate partials
req: examples/001 req: dx/008 req: list/003 req: form/002
This commit is contained in:
Generated
+19
@@ -389,6 +389,14 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hemplate"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hemplate-core",
|
||||||
|
"hemplate-derive",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hemplate-core"
|
name = "hemplate-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -399,6 +407,16 @@ dependencies = [
|
|||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hemplate-derive"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hemplate-core",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hemplate-parser"
|
name = "hemplate-parser"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1404,6 +1422,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"hemplate",
|
||||||
"scraper",
|
"scraper",
|
||||||
"slhx",
|
"slhx",
|
||||||
"slhx-axum",
|
"slhx-axum",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ path = "src/lib.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.7"
|
axum = "0.7"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
hemplate = { path = "../../../hemplate/hemplate" }
|
||||||
slhx = { path = "../../slhx" }
|
slhx = { path = "../../slhx" }
|
||||||
slhx-axum = { path = "../../slhx-axum" }
|
slhx-axum = { path = "../../slhx-axum" }
|
||||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
|
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ This is a polished Linear-style product demo for planning typed work across lane
|
|||||||
|
|
||||||
- modern SSR-first UI
|
- modern SSR-first UI
|
||||||
- generated resource modules from `.heml`
|
- generated resource modules from `.heml`
|
||||||
|
- hemplate partials for issue lanes, cards, and inspector panels
|
||||||
- native form posts carrying `__h` numeric handle ids
|
- native form posts carrying `__h` numeric handle ids
|
||||||
- multi-target `application/slhx` EffectBatch responses
|
- multi-target `application/slhx` EffectBatch responses
|
||||||
- generated slot updates instead of selectors
|
- generated slot updates instead of selectors
|
||||||
- root-scoped runtime lowering (`data-hid`, `data-sid`)
|
- root-scoped runtime lowering (`data-hid`, `data-sid`)
|
||||||
- page-enhancer navigation with native link fallback
|
- page-enhancer navigation with native link fallback
|
||||||
- SSE server push into a generated slot
|
- 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:
|
Verification:
|
||||||
|
|
||||||
|
|||||||
+171
-38
@@ -3,6 +3,7 @@ use axum::response::IntoResponse;
|
|||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use futures_util::{stream, StreamExt};
|
use futures_util::{stream, StreamExt};
|
||||||
|
use hemplate::Hemplate;
|
||||||
use slhx::{IntoEffect, SafeHtml};
|
use slhx::{IntoEffect, SafeHtml};
|
||||||
use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest};
|
use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest};
|
||||||
use slhx_techdemo::ui;
|
use slhx_techdemo::ui;
|
||||||
@@ -58,6 +59,7 @@ struct DemoState {
|
|||||||
work: Vec<WorkItem>,
|
work: Vec<WorkItem>,
|
||||||
activity: VecDeque<String>,
|
activity: VecDeque<String>,
|
||||||
spotlight: String,
|
spotlight: String,
|
||||||
|
selected_id: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DemoState {
|
impl Default for DemoState {
|
||||||
@@ -71,6 +73,7 @@ impl Default for DemoState {
|
|||||||
],
|
],
|
||||||
activity: VecDeque::new(),
|
activity: VecDeque::new(),
|
||||||
spotlight: "No selectors. Generated resources address every target.".into(),
|
spotlight: "No selectors. Generated resources address every target.".into(),
|
||||||
|
selected_id: Some(2),
|
||||||
};
|
};
|
||||||
state.log("Demo booted from server-rendered HTML");
|
state.log("Demo booted from server-rendered HTML");
|
||||||
state.log("Runtime attached one delegated listener per root");
|
state.log("Runtime attached one delegated listener per root");
|
||||||
@@ -91,6 +94,50 @@ struct Shared {
|
|||||||
demo: Mutex<DemoState>,
|
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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let state = Arc::new(Shared { demo: Mutex::new(DemoState::default()) });
|
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;
|
let id = demo.next_id;
|
||||||
demo.next_id += 1;
|
demo.next_id += 1;
|
||||||
demo.work.push(WorkItem { id, title: title.into(), lane, impact, stage: Stage::Draft });
|
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.spotlight = format!("Form data became typed Rust state; card #{id} was rendered by a generated slot.");
|
||||||
demo.log(format!("Launched card #{id}: {title}"));
|
demo.log(format!("Launched card #{id}: {title}"));
|
||||||
}
|
}
|
||||||
@@ -197,6 +245,28 @@ fn registry(shared: Arc<Shared>) -> HandlerRegistry {
|
|||||||
demo_effects(&demo, "Pipeline advanced")
|
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, {
|
.register(ui::control_center::handles::delete_work.id().id, {
|
||||||
let shared = shared.clone();
|
let shared = shared.clone();
|
||||||
move |form| {
|
move |form| {
|
||||||
@@ -206,6 +276,9 @@ fn registry(shared: Arc<Shared>) -> HandlerRegistry {
|
|||||||
let before = demo.work.len();
|
let before = demo.work.len();
|
||||||
demo.work.retain(|item| item.id != id);
|
demo.work.retain(|item| item.id != id);
|
||||||
if demo.work.len() < before {
|
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.spotlight = format!("Card #{id} removed; the board, metrics, activity, and inspector updated together.");
|
||||||
demo.log(format!("Deleted card #{id}"));
|
demo.log(format!("Deleted card #{id}"));
|
||||||
}
|
}
|
||||||
@@ -219,7 +292,8 @@ fn registry(shared: Arc<Shared>) -> HandlerRegistry {
|
|||||||
// req: examples/001
|
// req: examples/001
|
||||||
let mut demo = shared.demo.lock().unwrap();
|
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(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.spotlight = format!("{} · lane={} · stage={} · impact={}", item.title, LANES[item.lane].1, item.stage.label(), item.impact);
|
||||||
demo.log(format!("Inspected card #{id}"));
|
demo.log(format!("Inspected card #{id}"));
|
||||||
}
|
}
|
||||||
@@ -293,11 +367,7 @@ fn shell(body: String) -> String {
|
|||||||
<title>slhx Techdemo</title>
|
<title>slhx Techdemo</title>
|
||||||
<script src="/slhx.js" defer></script>
|
<script src="/slhx.js" defer></script>
|
||||||
<script>
|
<script>
|
||||||
function slhxTechdemoLaunch(button) {{
|
function slhxTechdemoEffect(root, data) {{
|
||||||
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'));
|
|
||||||
fetch('/', {{
|
fetch('/', {{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {{ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Accept': 'application/slhx' }},
|
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((response) => response.arrayBuffer())
|
||||||
.then((buffer) => window.slhx.applyBatch(buffer, root));
|
.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;
|
return false;
|
||||||
}}
|
}}
|
||||||
</script>
|
</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 {{ 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 h3 {{ margin:0 0 4px; }}
|
||||||
.lane p {{ color:var(--muted); margin:0 0 12px; font-size:13px; }}
|
.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; }}
|
.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); }}
|
.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; }}
|
.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); }}
|
.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 {{ 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; }}
|
.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); }}
|
.inspector-row b {{ color:var(--text); }}
|
||||||
.live-row strong {{ color:var(--lime); }}
|
.live-row strong {{ color:var(--lime); }}
|
||||||
code {{ color:var(--cyan); }}
|
code {{ color:var(--cyan); }}
|
||||||
@@ -388,37 +495,47 @@ fn render_hero(demo: &DemoState) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_board(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\">");
|
let mut out = String::from("<div class=\"lanes\">");
|
||||||
for (idx, (_, title, description)) in LANES.iter().enumerate() {
|
for lane in lanes {
|
||||||
out.push_str(&format!(r#"<section class="lane"><h3>{}</h3><p>{}</p>"#, escape_html(title), escape_html(description)));
|
out.push_str(&render_template(&lane));
|
||||||
for item in demo.work.iter().filter(|item| item.lane == idx) {
|
|
||||||
out.push_str(&render_card(item));
|
|
||||||
}
|
|
||||||
out.push_str("</section>");
|
|
||||||
}
|
}
|
||||||
out.push_str("</div>");
|
out.push_str("</div>");
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_card(item: &WorkItem) -> String {
|
fn issue_card(item: &WorkItem, selected: bool) -> IssueCard {
|
||||||
format!(
|
IssueCard {
|
||||||
r#"<article class="work-card" data-key="{id}">
|
class: if selected {
|
||||||
<header><strong>{title}</strong><span class="pill">{stage}</span></header>
|
String::from("work-card is-selected")
|
||||||
<div class="impact"><i style="width:{impact_percent}%"></i></div>
|
} else {
|
||||||
<div class="card-actions">
|
String::from("work-card")
|
||||||
<button type="button" data-hid="{spotlight}" data-work-id="{id}">Inspect</button>
|
},
|
||||||
<button type="button" data-hid="{advance}" data-work-id="{id}">Advance</button>
|
id: item.id,
|
||||||
<button type="button" data-hid="{delete}" data-work-id="{id}">Delete</button>
|
title: item.title.clone(),
|
||||||
</div>
|
stage: item.stage.label(),
|
||||||
</article>"#,
|
impact_style: format!("width:{}%", item.impact as usize * 11),
|
||||||
id = item.id,
|
spotlight_handle: ui::control_center::handles::spotlight_work.id().id,
|
||||||
title = escape_html(&item.title),
|
advance_handle: ui::control_center::handles::advance_work.id().id,
|
||||||
stage = item.stage.label(),
|
delete_handle: ui::control_center::handles::delete_work.id().id,
|
||||||
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 {
|
fn render_activity(demo: &DemoState) -> String {
|
||||||
@@ -431,12 +548,24 @@ fn render_activity(demo: &DemoState) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_inspector(demo: &DemoState) -> String {
|
fn render_inspector(demo: &DemoState) -> String {
|
||||||
format!(
|
let selected = demo
|
||||||
r#"<div class="inspector-row"><b>Selected fact</b><br>{}</div>
|
.selected_id
|
||||||
<div class="inspector-row"><b>Wire contract</b><br><code>POST __h → application/slhx → EffectBatch</code></div>
|
.and_then(|id| demo.work.iter().find(|item| item.id == id));
|
||||||
<div class="inspector-row"><b>Runtime</b><br>Root-scoped delegated listeners; numeric targets only.</div>"#,
|
let selected = selected.map_or_else(
|
||||||
escape_html(&demo.spotlight),
|
|| 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 {
|
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()
|
"<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 {
|
fn escape_html(value: &str) -> String {
|
||||||
value
|
value
|
||||||
.replace('&', "&")
|
.replace('&', "&")
|
||||||
|
|||||||
@@ -63,4 +63,5 @@
|
|||||||
<button type="button" data-slhx-handle="advance_work" hidden="hidden">Advance</button>
|
<button type="button" data-slhx-handle="advance_work" hidden="hidden">Advance</button>
|
||||||
<button type="button" data-slhx-handle="delete_work" hidden="hidden">Delete</button>
|
<button type="button" data-slhx-handle="delete_work" hidden="hidden">Delete</button>
|
||||||
<button type="button" data-slhx-handle="spotlight_work" hidden="hidden">Spotlight</button>
|
<button type="button" data-slhx-handle="spotlight_work" hidden="hidden">Spotlight</button>
|
||||||
|
<button type="button" data-slhx-handle="move_to_lane" hidden="hidden">Move to lane</button>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="inspector-hero">
|
||||||
|
<span>No issue selected</span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{+= self.selected =+}
|
||||||
|
<div class="inspector-row"><b>What happened</b><br>{+ self.spotlight +}</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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="inspector-hero">
|
||||||
|
<span>Selected issue</span>
|
||||||
|
<strong>{+ self.title +}</strong>
|
||||||
|
<em>{+ self.lane +} · {+ self.stage +} · impact {+ self.impact +}</em>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<article +class="self.class" +data-key="self.id" draggable="true" ondragstart="slhxTechdemoDrag(event)">
|
||||||
|
<header><strong>{+ self.title +}</strong><span class="pill">{+ self.stage +}</span></header>
|
||||||
|
<div class="impact"><i +style="self.impact_style"></i></div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button type="button" +data-hid="self.spotlight_handle" +data-work-id="self.id">Inspect</button>
|
||||||
|
<button type="button" +data-hid="self.advance_handle" +data-work-id="self.id">Advance</button>
|
||||||
|
<button type="button" +data-hid="self.delete_handle" +data-work-id="self.id">Delete</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<section +class="self.class" +data-lane="self.lane_id" +data-move-hid="self.move_handle" ondragover="slhxTechdemoDragOver(event)" ondragleave="slhxTechdemoDragLeave(event)" ondrop="slhxTechdemoDrop(event)">
|
||||||
|
<h3>{+ self.title +}</h3>
|
||||||
|
<p>{+ self.description +}</p>
|
||||||
|
<template h-for="card in &self.cards">
|
||||||
|
{+ card +}
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
@@ -81,6 +81,10 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
|
|||||||
wait_for_text(&driver, "body", "Browser verified issue").await?;
|
wait_for_text(&driver, "body", "Browser verified issue").await?;
|
||||||
assert_text(&driver, "width:88%").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
|
driver
|
||||||
.find(By::Css(&card_button_selector(ui::control_center::handles::spotlight_work.id().id, 4)))
|
.find(By::Css(&card_button_selector(ui::control_center::handles::spotlight_work.id().id, 4)))
|
||||||
.await?
|
.await?
|
||||||
@@ -89,7 +93,7 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
|
|||||||
wait_for_text(
|
wait_for_text(
|
||||||
&driver,
|
&driver,
|
||||||
&slot_selector(ui::control_center::slots::inspector.id().id),
|
&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?;
|
.await?;
|
||||||
|
|
||||||
@@ -98,7 +102,7 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
|
|||||||
.await?
|
.await?
|
||||||
.click()
|
.click()
|
||||||
.await?;
|
.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
|
driver
|
||||||
.find(By::Css(&handle_selector(ui::control_center::handles::simulate_push.id().id)))
|
.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}"]"#)
|
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<()> {
|
async fn wait_for_runtime(driver: &WebDriver) -> WebDriverResult<()> {
|
||||||
let deadline = Instant::now() + Duration::from_secs(8);
|
let deadline = Instant::now() + Duration::from_secs(8);
|
||||||
loop {
|
loop {
|
||||||
|
|||||||
@@ -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, "data-key=\"8\"");
|
||||||
assert_payload_not_contains(&missing_title_batch, "MUTATED");
|
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");
|
let inspect = post("/", "__h=3112123592&work_id=4");
|
||||||
assert_effect_response(&inspect);
|
assert_effect_response(&inspect);
|
||||||
let inspect_batch = inspect.batch();
|
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");
|
assert_payload_contains(&inspect_batch, "Inspector focused");
|
||||||
|
|
||||||
let advance = post("/", "__h=4085043678&work_id=4");
|
let advance = post("/", "__h=4085043678&work_id=4");
|
||||||
|
|||||||
Reference in New Issue
Block a user