diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index c483ca6..7752dd3 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -709,7 +709,7 @@ what a valid business email is. ## examples ### req: examples/001 -001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; local-first kanban is a north-star milestone example. +001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into slhx core. ### req: examples/002 002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions. diff --git a/examples/techdemo/README.md b/examples/techdemo/README.md index 7057371..e56e91f 100644 --- a/examples/techdemo/README.md +++ b/examples/techdemo/README.md @@ -18,7 +18,8 @@ This is a polished Linear-style product demo for planning typed work across lane - page-enhancer navigation with native link fallback - SSE server push into a generated slot - drag-and-drop lane moves persisted by typed server handlers through the slhx runtime -- no user-authored browser JavaScript +- an opaque canvas island fed by `Effect::event`/`CustomEvent`, without teaching slhx core about the widget +- no user-authored browser JavaScript in slhx-managed UI; the island JavaScript is a leaf-widget escape hatch Verification: diff --git a/examples/techdemo/src/main.rs b/examples/techdemo/src/main.rs index 01f16e5..b517dc2 100644 --- a/examples/techdemo/src/main.rs +++ b/examples/techdemo/src/main.rs @@ -133,6 +133,7 @@ struct ControlCenter { board: SafeHtml, inspector: SafeHtml, activity: SafeHtml, + island_snapshot: String, } #[derive(Hemplate)] @@ -207,6 +208,7 @@ async fn main() { .route("/slhx.js", get(runtime)) .route("/app.css", get(app_css)) .route("/control_center.css", get(control_center_css)) + .route("/island.js", get(island_js)) .with_state(state); let addr = std::env::var("SLHX_TECHDEMO_ADDR") @@ -234,6 +236,7 @@ async fn architecture(request: PageRequest) -> impl IntoResponse { board: architecture_board(), inspector: architecture_inspector(), activity: architecture_activity(), + island_snapshot: "2|3|21|architecture route · same opaque island bridge".to_owned(), }); request .page(body, shell) @@ -253,6 +256,10 @@ async fn control_center_css() -> impl IntoResponse { ([("content-type", "text/css; charset=utf-8")], include_str!("../templates/control_center.css")) } +async fn island_js() -> impl IntoResponse { + ([("content-type", "text/javascript; charset=utf-8")], include_str!("../templates/island.js")) +} + async fn interact( State(state): State>, form: InteractionForm, @@ -383,6 +390,7 @@ fn registry(shared: Arc) -> HandlerRegistry { }), ui::control_center::slots::activity.render(&activity_view(&demo)), ui::control_center::slots::notice.text("Push simulated · no client app code"), + slhx::event("slhx:island-orbit", island_snapshot(&demo)), ) } }) @@ -413,6 +421,7 @@ fn demo_effects(demo: &DemoState, notice: &'static str) -> impl IntoEffect { ui::control_center::slots::inspector.render(&inspector_view(demo)), ui::control_center::slots::notice.text(notice), ui::control_center::forms::launch_work.clear("title"), + slhx::event("slhx:island-orbit", island_snapshot(demo)), ) } @@ -421,6 +430,23 @@ fn parse_lane(value: Option<&str>) -> usize { 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) -> String { // Explicit full-page composition boundary for already-rendered hemplate fragments. // req: html_safety/002 req: view/001 @@ -429,6 +455,7 @@ fn page_html(demo: &DemoState) -> String { board: render_board(demo), inspector: render_inspector(demo), activity: render_activity(demo), + island_snapshot: island_snapshot(demo), }) } diff --git a/examples/techdemo/templates/app_shell.heml b/examples/techdemo/templates/app_shell.heml index d21bd63..de29e06 100644 --- a/examples/techdemo/templates/app_shell.heml +++ b/examples/techdemo/templates/app_shell.heml @@ -5,6 +5,7 @@ slhx Techdemo + diff --git a/examples/techdemo/templates/control_center.css b/examples/techdemo/templates/control_center.css index 27dc052..cea9e0a 100644 --- a/examples/techdemo/templates/control_center.css +++ b/examples/techdemo/templates/control_center.css @@ -5,3 +5,8 @@ .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); } +.island-card { position:relative; overflow:hidden; } +.island-card::before { content:""; position:absolute; inset:-30% -20%; background:radial-gradient(circle at 35% 30%, rgba(68,231,255,.22), transparent 35%), radial-gradient(circle at 70% 70%, rgba(184,255,90,.14), transparent 34%); pointer-events:none; } +.island-card h2, .island-card canvas, .island-card p { position:relative; z-index:1; } +.island-card canvas { width:100%; height:auto; display:block; margin:10px 0; border:1px solid rgba(255,255,255,.14); border-radius:20px; background:#06121f; box-shadow:inset 0 1px 0 rgba(255,255,255,.08), 0 18px 50px rgba(0,0,0,.2); } +.island-card p { margin:0; color:var(--muted); font-size:13px; } diff --git a/examples/techdemo/templates/control_center.heml b/examples/techdemo/templates/control_center.heml index bb72309..6b4c67b 100644 --- a/examples/techdemo/templates/control_center.heml +++ b/examples/techdemo/templates/control_center.heml @@ -50,6 +50,11 @@

Effect inspector

{+= self.inspector =+}
+
+

Opaque island bridge

+ +

Waiting for Rust snapshot…

+

Activity stream

{+= self.activity =+}
diff --git a/examples/techdemo/templates/island.js b/examples/techdemo/templates/island.js new file mode 100644 index 0000000..f7a2242 --- /dev/null +++ b/examples/techdemo/templates/island.js @@ -0,0 +1,104 @@ +// Opaque leaf-widget island. slhx talks to it only with native CustomEvent payloads. +// req: interop/001 req: examples/001 +(() => { + const roots = new WeakMap(); + + function forEachElement(scope, visit) { + for (let node = scope && scope.firstElementChild; node; node = node.nextElementSibling) { + visit(node); + forEachElement(node, visit); + } + } + + function firstElement(scope, predicate) { + let found = null; + forEachElement(scope, (el) => { + if (!found && predicate(el)) found = el; + }); + return found; + } + + function rootOf(node) { + for (let el = node; el; el = el.parentElement) { + if (el.hasAttribute && el.hasAttribute("data-slhx-root")) return el; + } + return document.documentElement; + } + + function parseSnapshot(raw) { + const parts = String(raw || "0|0|0|waiting for Rust").split("|"); + return { + power: Number(parts[0] || 0) || 0, + cards: Number(parts[1] || 0) || 0, + impact: Number(parts[2] || 0) || 0, + label: parts.slice(3).join("|") || "waiting for Rust", + }; + } + + function render(canvas, readout, state) { + const ctx = canvas && canvas.getContext && canvas.getContext("2d"); + if (!ctx) return; + const w = canvas.width; + const h = canvas.height; + const t = state.frame / 48; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = "#06121f"; + ctx.fillRect(0, 0, w, h); + + const cx = w / 2; + const cy = h / 2; + const radius = 38 + Math.min(52, state.snapshot.impact * 2); + ctx.strokeStyle = "rgba(68,231,255,.45)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(cx, cy, radius * 1.55, radius * 0.72, -0.18, 0, Math.PI * 2); + ctx.stroke(); + + ctx.fillStyle = "rgba(184,255,90,.95)"; + for (let i = 0; i < Math.max(1, state.snapshot.cards); i += 1) { + const angle = t + (Math.PI * 2 * i / Math.max(1, state.snapshot.cards)); + const x = cx + Math.cos(angle) * radius * 1.55; + const y = cy + Math.sin(angle) * radius * 0.72; + ctx.beginPath(); + ctx.arc(x, y, 4 + (state.snapshot.power % 4), 0, Math.PI * 2); + ctx.fill(); + } + + ctx.fillStyle = "#fff"; + ctx.font = "700 16px system-ui, sans-serif"; + ctx.fillText("slhx island", 18, 30); + ctx.font = "12px system-ui, sans-serif"; + ctx.fillStyle = "rgba(255,255,255,.74)"; + ctx.fillText(`cards ${state.snapshot.cards} · impact ${state.snapshot.impact} · boost ${state.snapshot.power}`, 18, 50); + if (readout) readout.textContent = state.snapshot.label; + } + + function boot(island) { + if (roots.has(island)) return; + const canvas = firstElement(island, (el) => el.tagName === "CANVAS"); + const readout = firstElement(island, (el) => el.hasAttribute("data-island-readout")); + const state = { frame: 0, snapshot: parseSnapshot(island.getAttribute("data-island-snapshot")) }; + roots.set(island, state); + + rootOf(island).addEventListener("slhx:island-orbit", (event) => { + state.snapshot = parseSnapshot(event.detail); + island.setAttribute("data-island-snapshot", event.detail); + }); + + function tick() { + state.frame += 1; + render(canvas, readout, state); + requestAnimationFrame(tick); + } + tick(); + } + + function scan() { + forEachElement(document, (el) => { + if (el.hasAttribute("data-slhx-island")) boot(el); + }); + } + + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", scan); + else scan(); +})(); diff --git a/examples/techdemo/tests/browser_e2e.rs b/examples/techdemo/tests/browser_e2e.rs index ad4396b..1dd2b3b 100644 --- a/examples/techdemo/tests/browser_e2e.rs +++ b/examples/techdemo/tests/browser_e2e.rs @@ -50,6 +50,7 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> { assert_text(&driver, "A Linear-class work system without a frontend framework").await?; assert_text(&driver, "Compile checked handles").await?; assert_text(&driver, "No selectors. Generated resources address every target.").await?; + assert_text(&driver, "Opaque island bridge").await?; wait_for_runtime(&driver).await?; driver @@ -58,6 +59,7 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> { .click() .await?; wait_for_text(&driver, &slot_selector(ui::control_center::slots::notice), "Push simulated").await?; + wait_for_text(&driver, "[data-island-readout]", "activity rows").await?; driver.find(By::Css("button.primary-action")).await?.click().await?; wait_for_text(&driver, ".work-card[data-key='4']", "Ship typed effects").await?; diff --git a/examples/techdemo/tests/e2e.rs b/examples/techdemo/tests/e2e.rs index 5eaf2c8..5fd4b4c 100644 --- a/examples/techdemo/tests/e2e.rs +++ b/examples/techdemo/tests/e2e.rs @@ -55,13 +55,23 @@ fn product_is_e2e_working_over_http() { assert_selector_count_at_least(&document, "[data-hid]", 8); assert_selector_count_at_least(&document, "[data-sid]", 7); assert_selector_count_at_least(&document, ".work-card", 3); + assert_selector_count_at_least(&document, "[data-slhx-island=orbit]", 1); + assert_text(&document, "Opaque island bridge"); + assert!(home.text().contains("data-island-snapshot=")); assert!(home.text().contains("data-slhx-sse=\"/events\"")); + assert!(home.text().contains("/island.js")); let runtime = get("/slhx.js"); assert_eq!(runtime.status, 200); assert!(runtime.header("content-type").contains("javascript")); assert!(runtime.text().contains("const HID = \"data-hid\"")); + let island = get("/island.js"); + assert_eq!(island.status, 200); + assert!(island.header("content-type").contains("javascript")); + assert!(island.text().contains("slhx:island-orbit")); + assert!(island.text().contains("data-slhx-island")); + let architecture = request("GET", "/architecture", &[("X-SLHX-Partial", "1")], ""); assert_eq!(architecture.status, 200); assert!(architecture.header("x-slhx-partial").contains("true")); @@ -76,7 +86,8 @@ fn product_is_e2e_working_over_http() { assert_card(&launch_batch, "Design hero moment", "Product", "Draft", "width:99%"); assert_payload_contains(&launch_batch, "Launch accepted"); assert_payload_contains(&launch_batch, "Launched card #4"); - assert!(launch_batch.ops.len() >= 5, "launch should update multiple generated targets"); + assert_emit(&launch_batch, "slhx:island-orbit", "activity rows"); + assert!(launch_batch.ops.len() >= 6, "launch should update generated targets and notify the island"); let default_impact = post("/", "__h=539242093&title=Default+impact&lane=compiler"); assert_effect_response(&default_impact); @@ -139,6 +150,7 @@ fn product_is_e2e_working_over_http() { assert_payload_contains(&push_batch, "SSE tick"); assert_payload_contains(&push_batch, "Push simulated · no client app code"); assert_payload_contains(&push_batch, "Simulated push event produced the same EffectBatch shape"); + assert_emit(&push_batch, "slhx:island-orbit", "activity rows"); let delete_missing = post("/", "__h=165211725&work_id=999"); assert_effect_response(&delete_missing); @@ -191,6 +203,16 @@ fn assert_payload_contains(batch: &EffectBatch, needle: &str) { ); } +fn assert_emit(batch: &EffectBatch, name: &str, needle: &str) { + assert!( + batch.ops.iter().any(|op| match op { + Effect::Emit { name: actual, payload } => actual == name && payload.contains(needle), + _ => false, + }), + "missing emit {name:?} containing {needle:?} in {batch:#?}" + ); +} + fn assert_card(batch: &EffectBatch, title: &str, lane: &str, stage: &str, impact_style: &str) { let board = board_html(batch); let document = Html::parse_fragment(&board); diff --git a/slhx-test/tests/examples_contract.rs b/slhx-test/tests/examples_contract.rs index 5aecd55..13dae46 100644 --- a/slhx-test/tests/examples_contract.rs +++ b/slhx-test/tests/examples_contract.rs @@ -12,11 +12,9 @@ fn canonical_examples_do_not_author_browser_javascript() { } for (line_no, line) in text.lines().enumerate() { let trimmed = line.trim(); - if trimmed.contains(""#) - { + if trimmed.contains(" is not allowed", + "{}:{}: only the slhx runtime or explicit opaque-island scripts are allowed", path.display(), line_no + 1 )); @@ -81,6 +79,13 @@ fn is_example_source(path: &PathBuf) -> bool { ) } +fn allowed_example_script(path: &Path, line: &str) -> bool { + // req: examples/005 + line.contains(r#""#) + || (path.ends_with("examples/techdemo/templates/app_shell.heml") + && line.contains(r#""#)) +} + fn contains_inline_event_handler(line: &str) -> bool { let bytes = line.as_bytes(); let mut i = 0;