polish(techdemo): make demo customer-ready
req: examples/001 req: dx/008 req: form/002
This commit is contained in:
@@ -16,7 +16,7 @@ This is a polished Linear-style product demo for planning typed work across lane
|
|||||||
- 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
|
||||||
- no user-authored browser JavaScript
|
- one tiny launch glue function plus slhx runtime-driven interactions
|
||||||
|
|
||||||
Verification:
|
Verification:
|
||||||
|
|
||||||
|
|||||||
@@ -292,18 +292,35 @@ fn shell(body: String) -> String {
|
|||||||
<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>
|
||||||
|
function slhxTechdemoLaunch(button) {{
|
||||||
|
const form = button.closest('form');
|
||||||
|
const root = button.closest('[data-slhx-root]');
|
||||||
|
const data = new URLSearchParams(new FormData(form));
|
||||||
|
data.set('__h', button.getAttribute('data-hid'));
|
||||||
|
fetch('/', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Accept': 'application/slhx' }},
|
||||||
|
body: data
|
||||||
|
}})
|
||||||
|
.then((response) => response.arrayBuffer())
|
||||||
|
.then((buffer) => window.slhx.applyBatch(buffer, root));
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
:root {{ color-scheme: dark; --bg:#070814; --panel:rgba(255,255,255,.08); --line:rgba(255,255,255,.16); --text:#f7f7ff; --muted:#aeb3d8; --hot:#ff4fd8; --cyan:#44e7ff; --lime:#b8ff5a; --amber:#ffd166; }}
|
:root {{ color-scheme: dark; --bg:#070814; --panel:rgba(255,255,255,.08); --line:rgba(255,255,255,.16); --text:#f7f7ff; --muted:#aeb3d8; --hot:#ff4fd8; --cyan:#44e7ff; --lime:#b8ff5a; --amber:#ffd166; }}
|
||||||
* {{ box-sizing:border-box; }}
|
* {{ box-sizing:border-box; min-width:0; }}
|
||||||
body {{ margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; color:var(--text); background: radial-gradient(circle at top left, rgba(68,231,255,.24), transparent 32rem), radial-gradient(circle at 80% 10%, rgba(255,79,216,.2), transparent 26rem), linear-gradient(135deg, #070814 0%, #111534 55%, #080916 100%); }}
|
html {{ font-feature-settings:"cv02","cv03","cv04","ss01"; text-rendering:geometricPrecision; }}
|
||||||
|
body {{ margin:0; min-height:100vh; font-family:Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; color:var(--text); background: radial-gradient(circle at 12% 8%, rgba(68,231,255,.28), transparent 28rem), radial-gradient(circle at 82% 4%, rgba(255,79,216,.22), transparent 24rem), radial-gradient(circle at 70% 70%, rgba(184,255,90,.08), transparent 30rem), linear-gradient(135deg, #070814 0%, #111534 55%, #080916 100%); overflow-x:hidden; }}
|
||||||
body::before {{ content:""; position:fixed; inset:0; pointer-events:none; background-image:linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px); background-size:42px 42px; mask-image:linear-gradient(to bottom, black, transparent); }}
|
body::before {{ content:""; position:fixed; inset:0; pointer-events:none; background-image:linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px),linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px); background-size:42px 42px; mask-image:linear-gradient(to bottom, black, transparent); }}
|
||||||
main {{ width:min(1180px, calc(100vw - 32px)); margin:0 auto; padding:38px 0 56px; }}
|
main {{ width:min(1180px, calc(100vw - 32px)); margin:0 auto; padding:38px 0 56px; }}
|
||||||
.hero-shell {{ display:grid; grid-template-columns:1.35fr .85fr; gap:22px; align-items:stretch; }}
|
.hero-shell {{ display:grid; grid-template-columns:1.35fr .85fr; gap:22px; align-items:stretch; }}
|
||||||
.hero-copy, .hero-panel, .command-card, .board-card, .glass-card, .topology {{ border:1px solid var(--line); background:linear-gradient(145deg, rgba(255,255,255,.12), rgba(255,255,255,.05)); box-shadow:0 24px 90px rgba(0,0,0,.32); backdrop-filter: blur(18px); border-radius:28px; }}
|
.hero-copy, .hero-panel, .command-card, .board-card, .glass-card, .topology {{ border:1px solid var(--line); background:linear-gradient(145deg, rgba(255,255,255,.14), rgba(255,255,255,.055)); box-shadow:0 24px 90px rgba(0,0,0,.36), inset 0 1px 0 rgba(255,255,255,.12); backdrop-filter: blur(22px) saturate(145%); border-radius:28px; }}
|
||||||
.hero-copy {{ padding:34px; overflow:hidden; position:relative; }}
|
.hero-copy {{ padding:34px; overflow:hidden; position:relative; }}
|
||||||
.hero-copy::after {{ content:"EffectBatch"; position:absolute; right:-18px; bottom:8px; font-size:86px; font-weight:900; color:rgba(255,255,255,.045); }}
|
.hero-copy::after {{ content:"EffectBatch"; position:absolute; right:-18px; bottom:8px; font-size:86px; font-weight:900; color:rgba(255,255,255,.045); }}
|
||||||
.eyebrow {{ color:var(--cyan); text-transform:uppercase; letter-spacing:.2em; font-weight:800; font-size:12px; }}
|
.eyebrow {{ color:var(--cyan); text-transform:uppercase; letter-spacing:.2em; font-weight:800; font-size:12px; }}
|
||||||
h1 {{ font-size:clamp(42px, 7vw, 82px); line-height:.91; letter-spacing:-.07em; margin:12px 0 18px; max-width:850px; }}
|
h1 {{ font-size:clamp(42px, 7vw, 84px); line-height:.88; letter-spacing:-.075em; margin:12px 0 18px; max-width:900px; text-wrap:balance; overflow-wrap:anywhere; }}
|
||||||
h2 {{ margin:0 0 16px; letter-spacing:-.035em; }}
|
h2 {{ margin:0 0 16px; letter-spacing:-.035em; }}
|
||||||
.lede {{ color:var(--muted); font-size:19px; line-height:1.55; max-width:720px; }}
|
.lede {{ color:var(--muted); font-size:19px; line-height:1.55; max-width:720px; }}
|
||||||
.hero-panel {{ padding:24px; }}
|
.hero-panel {{ padding:24px; }}
|
||||||
@@ -316,29 +333,31 @@ fn shell(body: String) -> String {
|
|||||||
.workspace {{ display:grid; grid-template-columns:360px 1fr; gap:18px; }}
|
.workspace {{ display:grid; grid-template-columns:360px 1fr; gap:18px; }}
|
||||||
.command-card, .board-card, .glass-card {{ padding:22px; }}
|
.command-card, .board-card, .glass-card {{ padding:22px; }}
|
||||||
label {{ display:grid; gap:8px; color:var(--muted); font-size:13px; margin:12px 0; }}
|
label {{ display:grid; gap:8px; color:var(--muted); font-size:13px; margin:12px 0; }}
|
||||||
input, select, button {{ width:100%; border:1px solid var(--line); border-radius:16px; color:var(--text); background:rgba(0,0,0,.25); padding:13px 14px; font:inherit; }}
|
input, select, button {{ width:100%; border:1px solid var(--line); border-radius:16px; color:var(--text); background:rgba(2,4,18,.55); padding:13px 14px; font:inherit; outline:none; transition:transform .16s ease, border-color .16s ease, background .16s ease, box-shadow .16s ease; }}
|
||||||
|
input:focus, select:focus {{ border-color:rgba(68,231,255,.75); box-shadow:0 0 0 4px rgba(68,231,255,.11); }}
|
||||||
button {{ cursor:pointer; font-weight:800; background:linear-gradient(135deg, rgba(68,231,255,.25), rgba(255,79,216,.22)); }}
|
button {{ cursor:pointer; font-weight:800; background:linear-gradient(135deg, rgba(68,231,255,.25), rgba(255,79,216,.22)); }}
|
||||||
button:hover {{ border-color:rgba(68,231,255,.7); transform:translateY(-1px); }}
|
.primary-action {{ background:linear-gradient(135deg, var(--cyan), var(--hot)); color:#050610; border:0; box-shadow:0 16px 42px rgba(68,231,255,.24); }}
|
||||||
|
button:hover {{ border-color:rgba(68,231,255,.7); transform:translateY(-1px); box-shadow:0 14px 40px rgba(0,0,0,.24); }}
|
||||||
.quick-actions {{ display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:12px; }}
|
.quick-actions {{ display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:12px; }}
|
||||||
.notice {{ color:var(--lime); min-height:1.4em; }}
|
.notice {{ color:var(--lime); min-height:1.4em; }}
|
||||||
.section-heading {{ display:flex; justify-content:space-between; gap:12px; color:var(--muted); margin-bottom:14px; }}
|
.section-heading {{ display:flex; justify-content:space-between; gap:12px; color:var(--muted); margin-bottom:14px; }}
|
||||||
.section-heading strong {{ color:var(--cyan); }}
|
.section-heading strong {{ color:var(--cyan); }}
|
||||||
.lanes {{ display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:14px; }}
|
.lanes {{ display:grid; grid-template-columns:repeat(3, minmax(220px, 1fr)); gap:14px; align-items:start; }}
|
||||||
.lane {{ min-height:330px; border:1px solid var(--line); border-radius:24px; padding:14px; background:rgba(0,0,0,.18); }}
|
.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,.13), rgba(255,255,255,.05)); }}
|
.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 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; }}
|
||||||
.impact i {{ display:block; height:100%; background:linear-gradient(90deg,var(--cyan),var(--hot)); }}
|
.impact i {{ display:block; height:100%; background:linear-gradient(90deg,var(--cyan),var(--hot)); }}
|
||||||
.card-actions {{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:8px; }}
|
.card-actions {{ display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:8px; }}
|
||||||
.card-actions button {{ padding:9px 10px; font-size:12px; }}
|
.card-actions button {{ padding:9px 10px; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }}
|
||||||
.insight-grid {{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:18px; margin-top:18px; }}
|
.insight-grid {{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:18px; margin-top:18px; }}
|
||||||
.glass-card {{ min-height:220px; }}
|
.glass-card {{ min-height:220px; }}
|
||||||
.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); }}
|
.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-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); }}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<main data-slhx-root="techdemo" data-slhx-sse="/events">
|
<main data-slhx-root="techdemo" data-slhx-sse="/events">
|
||||||
<section class="hero-shell">
|
<section class="hero-shell">
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<p class="eyebrow">Semantic, Laterally HX</p>
|
<p class="eyebrow">slhx Control Plane</p>
|
||||||
<h1>Rust owns the interaction graph. The browser executes tiny typed effects.</h1>
|
<h1>A Linear-class work system without a frontend framework.</h1>
|
||||||
<p class="lede">This live demo shows symbolic templates lowered to numeric ids, native forms, multi-target EffectBatches, page swapping, and server push — no app JavaScript.</p>
|
<p class="lede">Create, inspect, advance, and stream work through native HTML, generated typed resources, and compact EffectBatches. The UI feels app-grade; the model stays server-owned and boring.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-panel" data-slhx-slot="hero_metrics">__HERO__</div>
|
<div class="hero-panel" data-slhx-slot="hero_metrics">__HERO__</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
|
|
||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
<aside class="command-card">
|
<aside class="command-card">
|
||||||
<h2>Launch typed work</h2>
|
<h2>Create an issue</h2>
|
||||||
<form data-slhx-handle="launch_work" data-slhx-form="launch_work" data-slhx-disable-while-pending>
|
<form data-slhx-form="launch_work" data-slhx-disable-while-pending>
|
||||||
<label>Title <input name="title" required="required" value="Ship typed effects"></label>
|
<label>Title <input name="title" required="required" value="Ship typed effects"></label>
|
||||||
<label>Lane
|
<label>Lane
|
||||||
<select name="lane" required="required">
|
<select name="lane" required="required">
|
||||||
@@ -26,19 +26,20 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Impact <input name="impact" type="number" min="1" max="9" value="7"></label>
|
<label>Impact <input name="impact" type="number" min="1" max="9" value="7"></label>
|
||||||
<button type="submit">Launch work</button>
|
<button class="primary-action" type="button" data-slhx-handle="launch_work" onclick="return slhxTechdemoLaunch(this)">Create issue</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form hidden="hidden" data-slhx-handle="launch_work" data-slhx-form="launch_work"></form>
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
<button type="button" data-slhx-handle="simulate_push">Simulate server push</button>
|
<button type="button" data-slhx-handle="simulate_push">Simulate server push</button>
|
||||||
<button type="button" data-slhx-handle="reset_demo">Reset demo</button>
|
<button type="button" data-slhx-handle="reset_demo">Reset demo</button>
|
||||||
</div>
|
</div>
|
||||||
<p data-slhx-slot="notice" class="notice">Every control posts a numeric handle id and receives an EffectBatch.</p>
|
<p data-slhx-slot="notice" class="notice">Every control posts a numeric handle id and receives typed effects.</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="board-card">
|
<section class="board-card">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<span>Generated slots + keyed cards</span>
|
<span>Generated slots + keyed cards</span>
|
||||||
<strong>no selectors, no VDOM</strong>
|
<strong>generated resources, no selectors</strong>
|
||||||
</div>
|
</div>
|
||||||
<div data-slhx-slot="board">__BOARD__</div>
|
<div data-slhx-slot="board">__BOARD__</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
|
|||||||
|
|
||||||
let result = async {
|
let result = async {
|
||||||
driver.goto(&format!("http://{APP_ADDR}/")).await?;
|
driver.goto(&format!("http://{APP_ADDR}/")).await?;
|
||||||
assert_text(&driver, "Rust owns the interaction graph").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?;
|
||||||
wait_for_runtime(&driver).await?;
|
wait_for_runtime(&driver).await?;
|
||||||
@@ -70,25 +70,15 @@ async fn browser_drives_typed_product_end_to_end() -> WebDriverResult<()> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
driver
|
driver
|
||||||
.execute(
|
.find(By::Css(&format!(
|
||||||
&format!(
|
"button{}",
|
||||||
r#"
|
handle_selector(ui::control_center::handles::launch_work.id().id)
|
||||||
return fetch("/", {{
|
)))
|
||||||
method: "POST",
|
.await?
|
||||||
headers: {{ "Content-Type": "application/x-www-form-urlencoded" }},
|
.click()
|
||||||
body: "__h={}&title=Browser+verified+issue&lane=product&impact=8"
|
|
||||||
}})
|
|
||||||
.then((response) => response.arrayBuffer())
|
|
||||||
.then((buffer) => {{ window.slhx.applyBatch(buffer, document.querySelector("[data-slhx-root]")); return true; }});
|
|
||||||
"#,
|
|
||||||
ui::control_center::handles::launch_work.id().id
|
|
||||||
),
|
|
||||||
Vec::new(),
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
wait_for_text(&driver, &slot_selector(ui::control_center::slots::notice.id().id), "Launch accepted").await?;
|
wait_for_text(&driver, "body", "Browser verified issue").await?;
|
||||||
assert_text(&driver, "Browser verified issue").await?;
|
|
||||||
assert_text(&driver, "width:88%").await?;
|
assert_text(&driver, "width:88%").await?;
|
||||||
|
|
||||||
driver
|
driver
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
use slhx::{Effect, EffectBatch, Payload, EFFECT_BATCH_ABI_VERSION};
|
use slhx::{Effect, EffectBatch, Payload, EFFECT_BATCH_ABI_VERSION};
|
||||||
|
use slhx_techdemo::ui;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
@@ -47,7 +48,7 @@ fn product_is_e2e_working_over_http() {
|
|||||||
assert_eq!(home.status, 200);
|
assert_eq!(home.status, 200);
|
||||||
assert!(home.header("content-type").contains("text/html"));
|
assert!(home.header("content-type").contains("text/html"));
|
||||||
let document = Html::parse_document(home.text());
|
let document = Html::parse_document(home.text());
|
||||||
assert_text(&document, "Rust owns the interaction graph");
|
assert_text(&document, "A Linear-class work system without a frontend framework");
|
||||||
assert_text(&document, "Compile checked handles");
|
assert_text(&document, "Compile checked handles");
|
||||||
assert_text(&document, "Stream typed presence");
|
assert_text(&document, "Stream typed presence");
|
||||||
assert_selector_count_at_least(&document, "[data-slhx-root=techdemo]", 1);
|
assert_selector_count_at_least(&document, "[data-slhx-root=techdemo]", 1);
|
||||||
@@ -163,7 +164,7 @@ fn product_is_e2e_working_over_http() {
|
|||||||
fn assert_effect_response(response: &Response) {
|
fn assert_effect_response(response: &Response) {
|
||||||
assert_eq!(response.status, 200);
|
assert_eq!(response.status, 200);
|
||||||
assert!(response.header("content-type").contains("application/slhx"));
|
assert!(response.header("content-type").contains("application/slhx"));
|
||||||
assert_eq!(response.header("x-slhx-fingerprint"), "9481407110904740166");
|
assert_eq!(response.header("x-slhx-fingerprint"), ui::BUILD_FINGERPRINT.0.to_string());
|
||||||
let batch = response.batch();
|
let batch = response.batch();
|
||||||
assert_eq!(batch.abi_version, EFFECT_BATCH_ABI_VERSION);
|
assert_eq!(batch.abi_version, EFFECT_BATCH_ABI_VERSION);
|
||||||
assert!(!batch.ops.is_empty());
|
assert!(!batch.ops.is_empty());
|
||||||
|
|||||||
Reference in New Issue
Block a user