feat(techdemo): add opaque island bridge

Add a canvas leaf-widget island to the techdemo that receives server snapshots through Effect::event/CustomEvent while keeping slhx core and runtime semantics unchanged.

req: interop/001

req: examples/001

req: examples/005
This commit is contained in:
slhx agent
2026-05-26 07:29:51 +02:00
parent 28ee1b5111
commit 9d3f70e9fe
10 changed files with 179 additions and 7 deletions
+1 -1
View File
@@ -709,7 +709,7 @@ what a valid business email is.
## examples ## examples
### req: examples/001 ### 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 ### 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. 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.
+2 -1
View File
@@ -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 - page-enhancer navigation with native link fallback
- SSE server push into a generated slot - SSE server push into a generated slot
- drag-and-drop lane moves persisted by typed server handlers through the slhx runtime - 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: Verification:
+27
View File
@@ -133,6 +133,7 @@ struct ControlCenter {
board: SafeHtml, board: SafeHtml,
inspector: SafeHtml, inspector: SafeHtml,
activity: SafeHtml, activity: SafeHtml,
island_snapshot: String,
} }
#[derive(Hemplate)] #[derive(Hemplate)]
@@ -207,6 +208,7 @@ async fn main() {
.route("/slhx.js", get(runtime)) .route("/slhx.js", get(runtime))
.route("/app.css", get(app_css)) .route("/app.css", get(app_css))
.route("/control_center.css", get(control_center_css)) .route("/control_center.css", get(control_center_css))
.route("/island.js", get(island_js))
.with_state(state); .with_state(state);
let addr = std::env::var("SLHX_TECHDEMO_ADDR") let addr = std::env::var("SLHX_TECHDEMO_ADDR")
@@ -234,6 +236,7 @@ async fn architecture(request: PageRequest) -> impl IntoResponse {
board: architecture_board(), board: architecture_board(),
inspector: architecture_inspector(), inspector: architecture_inspector(),
activity: architecture_activity(), activity: architecture_activity(),
island_snapshot: "2|3|21|architecture route · same opaque island bridge".to_owned(),
}); });
request request
.page(body, shell) .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")) ([("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( async fn interact(
State(state): State<Arc<Shared>>, State(state): State<Arc<Shared>>,
form: InteractionForm, form: InteractionForm,
@@ -383,6 +390,7 @@ fn registry(shared: Arc<Shared>) -> HandlerRegistry {
}), }),
ui::control_center::slots::activity.render(&activity_view(&demo)), ui::control_center::slots::activity.render(&activity_view(&demo)),
ui::control_center::slots::notice.text("Push simulated · no client app code"), 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::inspector.render(&inspector_view(demo)),
ui::control_center::slots::notice.text(notice), ui::control_center::slots::notice.text(notice),
ui::control_center::forms::launch_work.clear("title"), 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) 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 { fn page_html(demo: &DemoState) -> String {
// Explicit full-page composition boundary for already-rendered hemplate fragments. // Explicit full-page composition boundary for already-rendered hemplate fragments.
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001
@@ -429,6 +455,7 @@ fn page_html(demo: &DemoState) -> String {
board: render_board(demo), board: render_board(demo),
inspector: render_inspector(demo), inspector: render_inspector(demo),
activity: render_activity(demo), activity: render_activity(demo),
island_snapshot: island_snapshot(demo),
}) })
} }
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>slhx Techdemo</title> <title>slhx Techdemo</title>
<script src="/slhx.js" defer></script> <script src="/slhx.js" defer></script>
<script src="/island.js" defer></script>
<link rel="stylesheet" href="/app.css"> <link rel="stylesheet" href="/app.css">
<link rel="stylesheet" href="/control_center.css"> <link rel="stylesheet" href="/control_center.css">
</head> </head>
@@ -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 { 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: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); } .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; }
@@ -50,6 +50,11 @@
<h2>Effect inspector</h2> <h2>Effect inspector</h2>
<div data-slhx-slot="inspector">{+= self.inspector =+}</div> <div data-slhx-slot="inspector">{+= self.inspector =+}</div>
</article> </article>
<article class="glass-card island-card" data-slhx-island="orbit" +data-island-snapshot="self.island_snapshot">
<h2>Opaque island bridge</h2>
<canvas width="360" height="180" aria-label="Animated island orbit"></canvas>
<p data-island-readout="">Waiting for Rust snapshot…</p>
</article>
<article class="glass-card"> <article class="glass-card">
<h2>Activity stream</h2> <h2>Activity stream</h2>
<div data-slhx-slot="activity">{+= self.activity =+}</div> <div data-slhx-slot="activity">{+= self.activity =+}</div>
+104
View File
@@ -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();
})();
+2
View File
@@ -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, "A Linear-class work system without a frontend framework").await?;
assert_text(&driver, "Compile checked handles").await?; assert_text(&driver, "Compile checked handles").await?;
assert_text(&driver, "No selectors. Generated resources address every target.").await?; assert_text(&driver, "No selectors. Generated resources address every target.").await?;
assert_text(&driver, "Opaque island bridge").await?;
wait_for_runtime(&driver).await?; wait_for_runtime(&driver).await?;
driver driver
@@ -58,6 +59,7 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
.click() .click()
.await?; .await?;
wait_for_text(&driver, &slot_selector(ui::control_center::slots::notice), "Push simulated").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?; driver.find(By::Css("button.primary-action")).await?.click().await?;
wait_for_text(&driver, ".work-card[data-key='4']", "Ship typed effects").await?; wait_for_text(&driver, ".work-card[data-key='4']", "Ship typed effects").await?;
+23 -1
View File
@@ -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-hid]", 8);
assert_selector_count_at_least(&document, "[data-sid]", 7); assert_selector_count_at_least(&document, "[data-sid]", 7);
assert_selector_count_at_least(&document, ".work-card", 3); 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("data-slhx-sse=\"/events\""));
assert!(home.text().contains("/island.js"));
let runtime = get("/slhx.js"); let runtime = get("/slhx.js");
assert_eq!(runtime.status, 200); assert_eq!(runtime.status, 200);
assert!(runtime.header("content-type").contains("javascript")); assert!(runtime.header("content-type").contains("javascript"));
assert!(runtime.text().contains("const HID = \"data-hid\"")); 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")], ""); let architecture = request("GET", "/architecture", &[("X-SLHX-Partial", "1")], "");
assert_eq!(architecture.status, 200); assert_eq!(architecture.status, 200);
assert!(architecture.header("x-slhx-partial").contains("true")); 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_card(&launch_batch, "Design hero moment", "Product", "Draft", "width:99%");
assert_payload_contains(&launch_batch, "Launch accepted"); assert_payload_contains(&launch_batch, "Launch accepted");
assert_payload_contains(&launch_batch, "Launched card #4"); 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"); let default_impact = post("/", "__h=539242093&title=Default+impact&lane=compiler");
assert_effect_response(&default_impact); 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, "SSE tick");
assert_payload_contains(&push_batch, "Push simulated · no client app code"); 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_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"); let delete_missing = post("/", "__h=165211725&work_id=999");
assert_effect_response(&delete_missing); 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) { fn assert_card(batch: &EffectBatch, title: &str, lane: &str, stage: &str, impact_style: &str) {
let board = board_html(batch); let board = board_html(batch);
let document = Html::parse_fragment(&board); let document = Html::parse_fragment(&board);
+9 -4
View File
@@ -12,11 +12,9 @@ fn canonical_examples_do_not_author_browser_javascript() {
} }
for (line_no, line) in text.lines().enumerate() { for (line_no, line) in text.lines().enumerate() {
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.contains("<script") if trimmed.contains("<script") && !allowed_example_script(path, trimmed) {
&& !trimmed.contains(r#"<script src="/slhx.js" defer></script>"#)
{
failures.push(format!( failures.push(format!(
"{}:{}: inline <script> is not allowed", "{}:{}: only the slhx runtime or explicit opaque-island scripts are allowed",
path.display(), path.display(),
line_no + 1 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#"<script src="/slhx.js" defer></script>"#)
|| (path.ends_with("examples/techdemo/templates/app_shell.heml")
&& line.contains(r#"<script src="/island.js" defer></script>"#))
}
fn contains_inline_event_handler(line: &str) -> bool { fn contains_inline_event_handler(line: &str) -> bool {
let bytes = line.as_bytes(); let bytes = line.as_bytes();
let mut i = 0; let mut i = 0;