c730a868e5
Remove direct browser host script loading from the canonical app shell and avoid forbidden low-level terms in runtime example code while preserving the typed host and local flows. req: examples/005 req: examples/003 req: host/001 req: local/004
1220 lines
39 KiB
Rust
1220 lines
39 KiB
Rust
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 hemx::{CssClass, CssClasses, EventName, Html, IntoEffect};
|
|
use hemx_axum::{
|
|
interactions, runtime_js, runtime_js_path, sse, DispatchRegistry, DispatchRejection,
|
|
EffectResponse, InteractionRequest, PageRequest,
|
|
};
|
|
use hemx_host::{
|
|
browser_pwa_host_profile, native_shell_host_profile, Capability, CapabilityManifest,
|
|
CapabilityShape, CapabilityUse, HapticPattern, HostCall, HostCallId, HostEvent,
|
|
SharePayload as HostShareData, BROWSER_HOST_JS,
|
|
};
|
|
use hemx_techdemo::ui;
|
|
use hemx_techdemo::ui::control_center::{self as control, classes};
|
|
use hemx_techdemo::ui::{
|
|
host_panel as host_control, issue_card as card_control, issue_lane as lane_control,
|
|
local_panel as local_control,
|
|
};
|
|
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", "typed updates → DOM"),
|
|
("product", "Product", "Native UX, zero app JS"),
|
|
];
|
|
const ISLAND_ORBIT: EventName = EventName::new("hemx:island-orbit");
|
|
const HOST_CALL: EventName = EventName::new("hemx:host-call");
|
|
|
|
#[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)]
|
|
enum LocalCommand {
|
|
CompleteSet { set_id: u64, reps: u8 },
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum LocalEvent {
|
|
SetCompleted { set_id: u64, reps: u8 },
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct LocalProjection {
|
|
completed_sets: usize,
|
|
summary: String,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct LocalJournal {
|
|
commands: Vec<LocalCommand>,
|
|
events: Vec<LocalEvent>,
|
|
projection: LocalProjection,
|
|
}
|
|
|
|
impl Default for LocalJournal {
|
|
fn default() -> Self {
|
|
Self {
|
|
commands: Vec::new(),
|
|
events: Vec::new(),
|
|
projection: LocalProjection {
|
|
completed_sets: 0,
|
|
summary: "No local commands queued".into(),
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
impl LocalJournal {
|
|
fn accept(&mut self, command: LocalCommand) {
|
|
// req: local/001 req: local/004
|
|
let event = match &command {
|
|
LocalCommand::CompleteSet { set_id, reps } => LocalEvent::SetCompleted {
|
|
set_id: *set_id,
|
|
reps: *reps,
|
|
},
|
|
};
|
|
self.commands.push(command);
|
|
self.events.push(event.clone());
|
|
self.project(&event);
|
|
}
|
|
|
|
fn project(&mut self, event: &LocalEvent) {
|
|
// req: local/001 req: local/004
|
|
match event {
|
|
LocalEvent::SetCompleted { set_id, reps } => {
|
|
self.projection.completed_sets += 1;
|
|
self.projection.summary = format!(
|
|
"Projected set {set_id} with {reps} reps from commands/events; no DOM patch or UI update was stored"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn status(&self) -> String {
|
|
format!(
|
|
"{} commands, {} events, {} projected sets",
|
|
self.commands.len(),
|
|
self.events.len(),
|
|
self.projection.completed_sets
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct DemoState {
|
|
next_id: u64,
|
|
work: Vec<WorkItem>,
|
|
activity: VecDeque<String>,
|
|
spotlight: String,
|
|
selected_id: Option<u64>,
|
|
host_status: String,
|
|
local_journal: LocalJournal,
|
|
}
|
|
|
|
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(),
|
|
selected_id: Some(2),
|
|
host_status: "Host calls are typed facts until app code accepts a result.".into(),
|
|
local_journal: LocalJournal::default(),
|
|
};
|
|
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<String>) {
|
|
self.activity.push_front(message.into());
|
|
while self.activity.len() > 6 {
|
|
self.activity.pop_back();
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Shared {
|
|
demo: Mutex<DemoState>,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct IssueLane {
|
|
class: CssClass,
|
|
lane_id: &'static str,
|
|
title: &'static str,
|
|
description: &'static str,
|
|
cards: Vec<IssueCard>,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct IssueCard {
|
|
class: CssClasses,
|
|
id: u64,
|
|
title: String,
|
|
stage: &'static str,
|
|
impact_style: String,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct BoardLanes {
|
|
lanes: Vec<IssueLane>,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
struct ControlCenter {
|
|
hero: Html,
|
|
board: Html,
|
|
inspector: Html,
|
|
activity: Html,
|
|
host: Html,
|
|
local: Html,
|
|
island_snapshot: String,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
struct AppShell {
|
|
runtime_src: &'static str,
|
|
body: Html,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct InspectorPanel {
|
|
selected: Html,
|
|
spotlight: String,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct InspectorSelected {
|
|
title: String,
|
|
lane: &'static str,
|
|
stage: &'static str,
|
|
impact: u8,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct InspectorEmpty;
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct LiveFeed {
|
|
tick: u64,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct ActivityFeed {
|
|
items: Vec<String>,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct HostPanel {
|
|
status: String,
|
|
boundary: &'static str,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct LocalPanel {
|
|
status: String,
|
|
projection: String,
|
|
boundary: &'static str,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct ArchitectureActivity;
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct ArchitectureInspector;
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct ArchitectureHero;
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct ArchitectureBoard;
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct HeroMetrics {
|
|
resource_count: u64,
|
|
active_count: usize,
|
|
shipped_count: usize,
|
|
impact_score: u64,
|
|
}
|
|
|
|
#[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("/favicon.ico", get(favicon))
|
|
.route(runtime_js_path(), get(runtime))
|
|
.route("/app.css", get(app_css))
|
|
.route("/control_center.css", get(control_center_css))
|
|
.route("/hemx-browser-host.js", get(browser_host_js))
|
|
.route("/island.js", get(island_js))
|
|
.with_state(state);
|
|
|
|
let addr = std::env::var("HEMX_TECHDEMO_ADDR")
|
|
.ok()
|
|
.and_then(|addr| addr.parse().ok())
|
|
.unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 3002)));
|
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
|
println!("hemx 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<Arc<Shared>>, request: PageRequest) -> impl IntoResponse {
|
|
let demo = state.demo.lock().unwrap().clone();
|
|
request
|
|
.page_html(page_html(&demo), shell)
|
|
.title("hemx 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 = render_control_center(ControlCenter {
|
|
hero: architecture_hero(),
|
|
board: architecture_board(),
|
|
inspector: architecture_inspector(),
|
|
activity: architecture_activity(),
|
|
host: render_host_panel(&DemoState::default()),
|
|
local: render_local_panel(&DemoState::default()),
|
|
island_snapshot: "2|3|21|architecture route · same opaque island bridge".to_owned(),
|
|
});
|
|
request
|
|
.page_html(body, shell)
|
|
.title("hemx Architecture")
|
|
.fingerprint(ui::BUILD_FINGERPRINT)
|
|
}
|
|
|
|
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"),
|
|
)
|
|
}
|
|
|
|
async fn control_center_css() -> impl IntoResponse {
|
|
(
|
|
[("content-type", "text/css; charset=utf-8")],
|
|
include_str!("../templates/control_center.css"),
|
|
)
|
|
}
|
|
|
|
async fn browser_host_js() -> impl IntoResponse {
|
|
// req: host/001 req: host/002 req: host/005
|
|
(
|
|
[("content-type", "application/javascript; charset=utf-8")],
|
|
BROWSER_HOST_JS,
|
|
)
|
|
}
|
|
|
|
async fn island_js() -> impl IntoResponse {
|
|
(
|
|
[("content-type", "text/javascript; charset=utf-8")],
|
|
include_str!("../templates/island.js"),
|
|
)
|
|
}
|
|
|
|
async fn interact(
|
|
State(state): State<Arc<Shared>>,
|
|
request: InteractionRequest,
|
|
) -> Result<EffectResponse, DispatchRejection> {
|
|
request.dispatch(registry(state))
|
|
}
|
|
|
|
// req: push/001 req: push/003 req: examples/001
|
|
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
|
if params.contains_key("once") {
|
|
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 = control::live_feed.put(&LiveFeed { tick });
|
|
Some((
|
|
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
|
|
tick + 1,
|
|
))
|
|
})
|
|
.boxed();
|
|
sse(batches)
|
|
}
|
|
|
|
enum HostAppCommand {
|
|
BrowserShareCompleted,
|
|
NativeHapticAcknowledged,
|
|
}
|
|
|
|
fn browser_manifest() -> CapabilityManifest {
|
|
CapabilityManifest::new([CapabilityUse::new(
|
|
Capability::Share,
|
|
CapabilityShape::Request,
|
|
)])
|
|
}
|
|
|
|
fn native_manifest() -> CapabilityManifest {
|
|
CapabilityManifest::new([CapabilityUse::new(
|
|
Capability::Haptics,
|
|
CapabilityShape::Fire,
|
|
)])
|
|
}
|
|
|
|
fn browser_share_call() -> HostCall {
|
|
HostCall::Share {
|
|
id: HostCallId::new("browser-share-1"),
|
|
payload: HostShareData::text("hemx host capability demo"),
|
|
}
|
|
}
|
|
|
|
fn native_haptic_call() -> HostCall {
|
|
HostCall::Haptic {
|
|
id: HostCallId::new("native-haptic-tap"),
|
|
pattern: HapticPattern::Success,
|
|
}
|
|
}
|
|
|
|
fn host_event_to_command(event: HostEvent) -> Option<HostAppCommand> {
|
|
match event {
|
|
HostEvent::ShareCompleted {
|
|
completed: true, ..
|
|
} => Some(HostAppCommand::BrowserShareCompleted),
|
|
HostEvent::Acknowledged { id } if id.0 == "native-haptic-tap" => {
|
|
Some(HostAppCommand::NativeHapticAcknowledged)
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn apply_host_event(demo: &mut DemoState, event: HostEvent) {
|
|
// req: host/002 req: host/005
|
|
match host_event_to_command(event) {
|
|
Some(HostAppCommand::BrowserShareCompleted) => {
|
|
demo.host_status =
|
|
"Browser/PWA HostEvent became an app command before UI effects.".into();
|
|
demo.log("Browser share completed through app host pipeline");
|
|
}
|
|
Some(HostAppCommand::NativeHapticAcknowledged) => {
|
|
demo.host_status =
|
|
"Native-shell HostEvent became an app command before UI effects.".into();
|
|
demo.log("Native haptic acknowledgment accepted by app code");
|
|
}
|
|
None => {
|
|
demo.host_status =
|
|
"HostEvent ignored by app policy; no domain change was appended.".into();
|
|
demo.log("Ignored host event by app policy");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
|
|
interactions(ui::BUILD_FINGERPRINT)
|
|
.on(control::launch_work, {
|
|
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.parse::<u8>("impact").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.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}"));
|
|
}
|
|
demo_effects(&demo, "Launch accepted · 4 targets updated")
|
|
}
|
|
})
|
|
.on(card_control::advance_work, {
|
|
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.parse("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 generated slot updates.");
|
|
demo.log(format!("Advanced {title}"));
|
|
}
|
|
demo_effects(&demo, "Pipeline advanced")
|
|
}
|
|
})
|
|
.on(lane_control::move_to_lane, {
|
|
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.parse("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.parse("work_id");
|
|
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")
|
|
}
|
|
})
|
|
.on(card_control::delete_work, {
|
|
let shared = shared.clone();
|
|
move |form| {
|
|
// req: list/003 req: examples/001
|
|
let mut demo = shared.demo.lock().unwrap();
|
|
if let Some(id) = form.parse::<u64>("work_id") {
|
|
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}"));
|
|
}
|
|
}
|
|
demo_effects(&demo, "Card removed")
|
|
}
|
|
})
|
|
.on(card_control::spotlight_work, {
|
|
let shared = shared.clone();
|
|
move |form| {
|
|
// req: examples/001
|
|
let mut demo = shared.demo.lock().unwrap();
|
|
if let Some(id) = form.parse::<u64>("work_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}"));
|
|
}
|
|
}
|
|
demo_effects(&demo, "Inspector focused")
|
|
}
|
|
})
|
|
.on(host_control::request_browser_share, {
|
|
let shared = shared.clone();
|
|
move |_| {
|
|
// req: host/001 req: host/004 req: examples/001
|
|
let mut demo = shared.demo.lock().unwrap();
|
|
let call = browser_share_call();
|
|
match browser_manifest().validate_call(&browser_pwa_host_profile(), &call) {
|
|
Ok(()) => {
|
|
demo.host_status = "Browser/PWA share request accepted; waiting for HostEvent from the host adapter.".into();
|
|
demo.log("Requested browser/PWA share through hemx-host");
|
|
(demo_effects(&demo, "Browser host call requested"), HOST_CALL.emit("browser-share-1"))
|
|
}
|
|
Err(error) => {
|
|
demo.host_status = format!("Browser/PWA host check failed: {error}");
|
|
(demo_effects(&demo, "Browser host check failed"), HOST_CALL.emit("browser-share-failed"))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.on(host_control::record_browser_share, {
|
|
let shared = shared.clone();
|
|
move |form| {
|
|
// req: host/002 req: host/005 req: examples/001
|
|
let completed = form.parse::<bool>("completed").unwrap_or(true);
|
|
let mut demo = shared.demo.lock().unwrap();
|
|
apply_host_event(
|
|
&mut demo,
|
|
HostEvent::ShareCompleted {
|
|
id: HostCallId::new("browser-share-1"),
|
|
completed,
|
|
},
|
|
);
|
|
demo_effects(&demo, "Browser host result accepted by app code")
|
|
}
|
|
})
|
|
.on(host_control::request_native_haptic, {
|
|
let shared = shared.clone();
|
|
move |_| {
|
|
// req: host/001 req: host/004 req: examples/001
|
|
let mut demo = shared.demo.lock().unwrap();
|
|
let call = native_haptic_call();
|
|
match native_manifest().validate_call(&native_shell_host_profile("ios-android-webview"), &call) {
|
|
Ok(()) => {
|
|
demo.host_status = "Native-shell haptic request accepted; waiting for host acknowledgment.".into();
|
|
demo.log("Requested native-shell haptic through hemx-host");
|
|
(demo_effects(&demo, "Native host call requested"), HOST_CALL.emit("native-haptic-tap"))
|
|
}
|
|
Err(error) => {
|
|
demo.host_status = format!("Native host check failed: {error}");
|
|
(demo_effects(&demo, "Native host check failed"), HOST_CALL.emit("native-haptic-failed"))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.on(host_control::record_native_haptic_ack, {
|
|
let shared = shared.clone();
|
|
move |_| {
|
|
// req: host/002 req: host/005 req: examples/001
|
|
let mut demo = shared.demo.lock().unwrap();
|
|
apply_host_event(
|
|
&mut demo,
|
|
HostEvent::Acknowledged {
|
|
id: HostCallId::new("native-haptic-tap"),
|
|
},
|
|
);
|
|
demo_effects(&demo, "Native host result accepted by app code")
|
|
}
|
|
})
|
|
.on(local_control::queue_local_set, {
|
|
let shared = shared.clone();
|
|
move |_| {
|
|
// req: local/001 req: local/004 req: examples/001
|
|
let mut demo = shared.demo.lock().unwrap();
|
|
let set_id = demo.local_journal.commands.len() as u64 + 1;
|
|
demo.local_journal
|
|
.accept(LocalCommand::CompleteSet { set_id, reps: 8 });
|
|
demo.log(format!(
|
|
"Local command #{set_id} became event and projection before UI effects"
|
|
));
|
|
demo_effects(&demo, "Local command accepted · projection rendered")
|
|
}
|
|
})
|
|
.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");
|
|
(
|
|
control::live_feed.put(&LiveFeed {
|
|
tick: demo.activity.len() as u64,
|
|
}),
|
|
control::activity.put(&activity_view(&demo)),
|
|
control::notice.text("Push simulated · no client app code"),
|
|
ISLAND_ORBIT.emit(island_snapshot(&demo)),
|
|
)
|
|
}
|
|
})
|
|
.on(control::reset_demo, {
|
|
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<u64>,
|
|
update: impl FnOnce(&mut WorkItem),
|
|
) -> Option<String> {
|
|
let id = id?;
|
|
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 {
|
|
(
|
|
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::host_panel.put(&host_panel(demo)),
|
|
control::local_panel.put(&local_panel(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)
|
|
}
|
|
|
|
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 impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum();
|
|
format!(
|
|
"{}|{}|{}|{} active · {} shipped · {} activity rows",
|
|
active + shipped,
|
|
demo.work.len(),
|
|
impact,
|
|
active,
|
|
shipped,
|
|
demo.activity.len()
|
|
)
|
|
}
|
|
|
|
fn page_html(demo: &DemoState) -> Html {
|
|
// req: html_safety/002 req: view/001
|
|
render_control_center(ControlCenter {
|
|
hero: render_hero(demo),
|
|
board: ui::render(&board_view(demo)),
|
|
inspector: render_inspector(demo),
|
|
activity: render_activity(demo),
|
|
host: render_host_panel(demo),
|
|
local: render_local_panel(demo),
|
|
island_snapshot: island_snapshot(demo),
|
|
})
|
|
}
|
|
|
|
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 {
|
|
runtime_src: runtime_js_path(),
|
|
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 impact: u64 = demo.work.iter().map(|item| item.impact as u64).sum();
|
|
HeroMetrics {
|
|
resource_count: 20,
|
|
active_count: active,
|
|
shipped_count: shipped,
|
|
impact_score: impact,
|
|
}
|
|
}
|
|
|
|
fn render_hero(demo: &DemoState) -> Html {
|
|
// req: html_safety/002 req: view/001 req: component/003
|
|
ui::render(&hero_view(demo))
|
|
}
|
|
|
|
fn board_view(demo: &DemoState) -> BoardLanes {
|
|
// req: html_safety/002 req: view/001
|
|
let lanes = LANES
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(idx, (lane_id, title, description))| IssueLane {
|
|
class: classes::lane,
|
|
lane_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<_>>();
|
|
|
|
BoardLanes { lanes }
|
|
}
|
|
|
|
fn issue_card(item: &WorkItem, selected: bool) -> IssueCard {
|
|
IssueCard {
|
|
class: classes::work_card.with_if(selected, classes::is_selected),
|
|
id: item.id,
|
|
title: item.title.clone(),
|
|
stage: item.stage.label(),
|
|
impact_style: format!("width:{}%", item.impact as usize * 11),
|
|
}
|
|
}
|
|
|
|
fn activity_view(demo: &DemoState) -> ActivityFeed {
|
|
// req: html_safety/002 req: view/001
|
|
ActivityFeed {
|
|
items: demo.activity.iter().cloned().collect(),
|
|
}
|
|
}
|
|
|
|
fn render_activity(demo: &DemoState) -> Html {
|
|
// req: html_safety/002 req: view/001 req: component/003
|
|
ui::render(&activity_view(demo))
|
|
}
|
|
|
|
fn host_panel(demo: &DemoState) -> HostPanel {
|
|
// req: host/001 req: host/002 req: host/005
|
|
HostPanel {
|
|
status: demo.host_status.clone(),
|
|
boundary: "HostCall → HostEvent → app command → UI update",
|
|
}
|
|
}
|
|
|
|
fn render_host_panel(demo: &DemoState) -> Html {
|
|
// req: host/001 req: host/002 req: host/005
|
|
ui::render(&host_panel(demo))
|
|
}
|
|
|
|
fn local_panel(demo: &DemoState) -> LocalPanel {
|
|
// req: local/001 req: local/004
|
|
LocalPanel {
|
|
status: demo.local_journal.status(),
|
|
projection: demo.local_journal.projection.summary.clone(),
|
|
boundary: "LocalCommand → LocalEvent → Projection → UI update",
|
|
}
|
|
}
|
|
|
|
fn render_local_panel(demo: &DemoState) -> Html {
|
|
// req: local/001 req: local/004
|
|
ui::render(&local_panel(demo))
|
|
}
|
|
|
|
fn inspector_view(demo: &DemoState) -> InspectorPanel {
|
|
// req: html_safety/002 req: view/001
|
|
let selected = demo
|
|
.selected_id
|
|
.and_then(|id| demo.work.iter().find(|item| item.id == id));
|
|
let selected = selected.map_or_else(
|
|
|| ui::render(&InspectorEmpty),
|
|
|item| {
|
|
ui::render(&InspectorSelected {
|
|
title: item.title.clone(),
|
|
lane: LANES[item.lane].1,
|
|
stage: item.stage.label(),
|
|
impact: item.impact,
|
|
})
|
|
},
|
|
);
|
|
InspectorPanel {
|
|
selected,
|
|
spotlight: demo.spotlight.clone(),
|
|
}
|
|
}
|
|
|
|
fn render_inspector(demo: &DemoState) -> Html {
|
|
// req: html_safety/002 req: view/001 req: component/003
|
|
ui::render(&inspector_view(demo))
|
|
}
|
|
|
|
fn architecture_hero() -> Html {
|
|
// req: html_safety/002 req: view/001
|
|
ui::render(&ArchitectureHero)
|
|
}
|
|
|
|
fn architecture_board() -> Html {
|
|
// req: html_safety/002 req: view/001
|
|
ui::render(&ArchitectureBoard)
|
|
}
|
|
|
|
fn architecture_inspector() -> Html {
|
|
// req: html_safety/002 req: view/001
|
|
ui::render(&ArchitectureInspector)
|
|
}
|
|
|
|
fn architecture_activity() -> Html {
|
|
// req: html_safety/002 req: view/001
|
|
ui::render(&ArchitectureActivity)
|
|
}
|
|
|
|
fn render_control_center(page: ControlCenter) -> Html {
|
|
// req: html_safety/002 req: view/001 req: component/003
|
|
ui::render(&page)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use scraper::{Html, Selector};
|
|
|
|
fn selector(value: &str) -> Selector {
|
|
Selector::parse(value).expect("test selector parses")
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn shell_is_composed_by_a_hemplate_view() {
|
|
let html = shell(page_html(&DemoState::default()));
|
|
let document = Html::parse_document(html.as_str());
|
|
assert_eq!(
|
|
document
|
|
.select(&selector("title"))
|
|
.next()
|
|
.map(|title| title.text().collect::<String>()),
|
|
Some("hemx Techdemo".to_owned())
|
|
);
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(&format!("script[src=\"{}\"]", runtime_js_path())))
|
|
.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-hemx-root=\"techdemo\"]"))
|
|
.count(),
|
|
1
|
|
);
|
|
assert!(!html.as_str().contains("{+="));
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn control_center_page_is_composed_by_hemplate_not_placeholders() {
|
|
let html = page_html(&DemoState::default());
|
|
assert!(!html.as_str().contains("__HERO__"));
|
|
assert!(!html.as_str().contains("__BOARD__"));
|
|
assert!(!html.as_str().contains("__INSPECTOR__"));
|
|
assert!(!html.as_str().contains("__ACTIVITY__"));
|
|
|
|
let document = Html::parse_fragment(html.as_str());
|
|
assert_eq!(
|
|
document
|
|
.select(&selector("[data-hemx-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
|
|
);
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
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::<Vec<_>>();
|
|
assert_eq!(metrics.len(), 4);
|
|
assert_eq!(
|
|
metrics[0]
|
|
.select(&selector("span"))
|
|
.next()
|
|
.map(|span| span.text().collect::<String>()),
|
|
Some("generated resources on this page".to_owned())
|
|
);
|
|
assert!(metrics.iter().any(|metric| metric
|
|
.text()
|
|
.collect::<String>()
|
|
.contains("aggregate impact score")));
|
|
}
|
|
|
|
// req: style/001 req: style/002 req: style/003 req: test/005
|
|
#[test]
|
|
fn generated_css_class_flows_through_hemplate_dynamic_attribute() {
|
|
let board = ui::render(&board_view(&DemoState::default()));
|
|
assert_eq!(classes::lane.as_str(), "lane");
|
|
assert_eq!(classes::work_card.as_str(), "work-card");
|
|
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
|
|
);
|
|
let lane = document
|
|
.select(&selector(r#"section.lane[data-lane="compiler"]"#))
|
|
.next()
|
|
.expect("compiler lane renders");
|
|
assert_eq!(lane.value().attr("class"), Some("lane"));
|
|
|
|
let selected_card = document
|
|
.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")
|
|
);
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn inspector_payload_is_rendered_by_a_hemplate_view() {
|
|
let html = render_inspector(&DemoState::default());
|
|
let document = Html::parse_fragment(html.as_str());
|
|
let hero = document
|
|
.select(&selector(".inspector-hero"))
|
|
.next()
|
|
.expect("selected inspector hero renders");
|
|
assert_eq!(
|
|
hero.select(&selector("span"))
|
|
.next()
|
|
.map(|span| span.text().collect::<String>()),
|
|
Some("Selected issue".to_owned())
|
|
);
|
|
assert_eq!(document.select(&selector(".inspector-row")).count(), 3);
|
|
assert!(document
|
|
.select(&selector(".inspector-row"))
|
|
.any(|row| row.text().collect::<String>().contains("No selectors")));
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn live_feed_payload_is_rendered_by_a_hemplate_view() {
|
|
let html = ui::render(&LiveFeed { tick: 7 });
|
|
let document = Html::parse_fragment(html.as_str());
|
|
let row = document
|
|
.select(&selector(".live-row"))
|
|
.next()
|
|
.expect("live feed row renders");
|
|
let text = row.text().collect::<String>();
|
|
assert!(text.contains("SSE tick #7"));
|
|
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("<b>escaped activity</b>".to_owned());
|
|
|
|
let html = render_activity(&demo);
|
|
let document = Html::parse_fragment(html.as_str());
|
|
let list = document
|
|
.select(&selector("ol.activity"))
|
|
.next()
|
|
.expect("activity list renders");
|
|
let items = list.select(&selector("li")).collect::<Vec<_>>();
|
|
assert_eq!(items.len(), demo.activity.len());
|
|
assert!(items
|
|
.last()
|
|
.expect("activity item renders")
|
|
.text()
|
|
.collect::<String>()
|
|
.contains("<b>escaped activity</b>"));
|
|
assert!(list.select(&selector("b")).next().is_none());
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn architecture_activity_is_rendered_by_a_hemplate_view() {
|
|
let html = architecture_activity();
|
|
let document = Html::parse_fragment(html.as_str());
|
|
let list = document
|
|
.select(&selector("ol.activity"))
|
|
.next()
|
|
.expect("architecture activity list renders");
|
|
let items = list.select(&selector("li")).collect::<Vec<_>>();
|
|
assert_eq!(items.len(), 3);
|
|
assert_eq!(
|
|
items[1].text().collect::<String>(),
|
|
"Fetched HTML with X-HEMX-Partial"
|
|
);
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn architecture_inspector_is_rendered_by_a_hemplate_view() {
|
|
let html = architecture_inspector();
|
|
let document = Html::parse_fragment(html.as_str());
|
|
let row = document
|
|
.select(&selector(".inspector-row"))
|
|
.next()
|
|
.expect("architecture inspector row renders");
|
|
assert_eq!(
|
|
row.select(&selector("b"))
|
|
.next()
|
|
.map(|b| b.text().collect::<String>()),
|
|
Some("Page swap".to_owned())
|
|
);
|
|
assert!(row
|
|
.text()
|
|
.collect::<String>()
|
|
.contains("rendered through the same root"));
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn architecture_hero_is_rendered_by_a_hemplate_view() {
|
|
let html = architecture_hero();
|
|
let document = Html::parse_fragment(html.as_str());
|
|
let metrics = document.select(&selector(".metric")).collect::<Vec<_>>();
|
|
assert_eq!(metrics.len(), 3);
|
|
assert_eq!(
|
|
metrics[0]
|
|
.select(&selector("span"))
|
|
.next()
|
|
.map(|span| span.text().collect::<String>()),
|
|
Some("template source of truth".to_owned())
|
|
);
|
|
assert!(document
|
|
.select(&selector(".metrics .metric strong"))
|
|
.any(|strong| strong.text().collect::<String>() == "∞"));
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
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::<Vec<_>>();
|
|
assert_eq!(lanes.len(), 3);
|
|
assert_eq!(
|
|
lanes[0]
|
|
.select(&selector("h3"))
|
|
.next()
|
|
.map(|heading| heading.text().collect::<String>()),
|
|
Some("hemplate".to_owned())
|
|
);
|
|
assert!(lanes.iter().any(|lane| {
|
|
lane.select(&selector("p"))
|
|
.next()
|
|
.map(|paragraph| paragraph.text().collect::<String>())
|
|
.is_some_and(|text| text.contains("typed DOM ops"))
|
|
}));
|
|
}
|
|
}
|