v0.1.0-alpha: interactive demo platform core
- Fastify API: demos/steps CRUD, content-addressed assets, scoped API keys (read/author/publish/admin, draft-only default per D4) - Player: image + sandboxed DOM steps, hotspots, tooltips, keyboard nav, privacy-first beacons (no IP storage, viewer tokens only — R7) - Editor: React SPA — demo list, drag-to-draw hotspots, annotations, publish - MCP server: Streamable HTTP, scope-filtered tools, untrusted-data wrapping on viewer-supplied content (R8); platform never calls a model - Capture extension (MV3): screenshot-flow recording, hotspots pre-placed from real clicks - Docker: single app image + postgres, Traefik-ready (expose only) E2E verified: capture->author->publish->play->beacon->lead->analytics + full MCP tool surface with scope filtering.
This commit is contained in:
commit
9b09f14916
33 changed files with 5555 additions and 0 deletions
39
apps/api/public/player.html
Normal file
39
apps/api/public/player.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Demo</title>
|
||||
<style>
|
||||
:root { --accent: #2563eb; --bg: #f8fafc; --fg: #0f172a; }
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; height: 100vh; display: flex; flex-direction: column; }
|
||||
#stage { position: relative; flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; padding: 12px; }
|
||||
#snap { position: relative; max-width: 100%; max-height: 100%; box-shadow: 0 4px 24px rgba(15,23,42,.12); border-radius: 8px; overflow: hidden; background:#fff; }
|
||||
#snap img { display: block; max-width: 100%; max-height: calc(100vh - 90px); }
|
||||
#snap iframe { border: 0; width: 1280px; height: 720px; max-width: 100%; }
|
||||
.hotspot { position: absolute; border: 2px solid var(--accent); border-radius: 6px; cursor: pointer; background: rgba(37,99,235,.08); animation: pulse 1.6s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(37,99,235,.35);} 50% { box-shadow: 0 0 0 8px rgba(37,99,235,0);} }
|
||||
.tooltip { position: absolute; max-width: 280px; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; box-shadow: 0 8px 30px rgba(15,23,42,.15); z-index: 5; }
|
||||
.tooltip h3 { font-size: 14px; margin-bottom: 4px; }
|
||||
.tooltip p { font-size: 13px; color: #475569; }
|
||||
#bar { display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: #fff; border-top: 1px solid #e2e8f0; }
|
||||
#dots { display: flex; gap: 6px; flex: 1; justify-content: center; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: #cbd5e1; }
|
||||
.dot.active { background: var(--accent); }
|
||||
button { border: 1px solid #e2e8f0; background: #fff; border-radius: 6px; padding: 6px 14px; cursor: pointer; font: inherit; }
|
||||
button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
#title { font-weight: 600; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage"><div id="snap"></div></div>
|
||||
<div id="bar">
|
||||
<span id="title"></span>
|
||||
<div id="dots"></div>
|
||||
<button id="prev">Back</button>
|
||||
<button id="next">Next</button>
|
||||
</div>
|
||||
<script src="/player.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
113
apps/api/public/player.js
Normal file
113
apps/api/public/player.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/* Demo player. Renders image or dom steps with hotspot overlay; emits privacy-first beacons. */
|
||||
(function () {
|
||||
"use strict";
|
||||
var token = location.pathname.split("/").pop();
|
||||
var params = new URLSearchParams(location.search);
|
||||
var viewerToken = params.get("v") || null; // R7: identity only via explicit link token
|
||||
|
||||
var state = { demo: null, i: 0 };
|
||||
var snap = document.getElementById("snap");
|
||||
var dots = document.getElementById("dots");
|
||||
var titleEl = document.getElementById("title");
|
||||
|
||||
function beacon(event, stepId) {
|
||||
if (!state.demo) return;
|
||||
var payload = JSON.stringify({
|
||||
demo_id: state.demo.demo_id,
|
||||
step_id: stepId || null,
|
||||
viewer_token: viewerToken,
|
||||
event: event,
|
||||
});
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon("/api/public/analytics", new Blob([payload], { type: "application/json" }));
|
||||
} else {
|
||||
fetch("/api/public/analytics", { method: "POST", headers: { "Content-Type": "application/json" }, body: payload });
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
var step = state.demo.steps[state.i];
|
||||
snap.innerHTML = "";
|
||||
|
||||
var container;
|
||||
if (step.snapshot_type === "image") {
|
||||
container = document.createElement("img");
|
||||
container.src = "/assets/" + step.asset_id;
|
||||
container.draggable = false;
|
||||
} else {
|
||||
container = document.createElement("iframe");
|
||||
container.setAttribute("sandbox", ""); // R1/R2: fully inert document, no scripts
|
||||
container.srcdoc = step.dom_html || "";
|
||||
}
|
||||
snap.appendChild(container);
|
||||
|
||||
if (step.hotspot) {
|
||||
var h = document.createElement("div");
|
||||
h.className = "hotspot";
|
||||
h.style.left = step.hotspot.x + "%";
|
||||
h.style.top = step.hotspot.y + "%";
|
||||
h.style.width = step.hotspot.w + "%";
|
||||
h.style.height = step.hotspot.h + "%";
|
||||
h.addEventListener("click", next);
|
||||
snap.appendChild(h);
|
||||
|
||||
if (step.annotation) {
|
||||
var t = document.createElement("div");
|
||||
t.className = "tooltip";
|
||||
var below = step.hotspot.y < 70;
|
||||
t.style.left = Math.min(step.hotspot.x, 70) + "%";
|
||||
t.style.top = below ? (step.hotspot.y + step.hotspot.h + 2) + "%" : "auto";
|
||||
if (!below) t.style.bottom = (100 - step.hotspot.y + 2) + "%";
|
||||
t.innerHTML =
|
||||
(step.annotation.title ? "<h3></h3>" : "") + (step.annotation.body ? "<p></p>" : "");
|
||||
if (step.annotation.title) t.querySelector("h3").textContent = step.annotation.title;
|
||||
if (step.annotation.body) t.querySelector("p").textContent = step.annotation.body;
|
||||
snap.appendChild(t);
|
||||
}
|
||||
}
|
||||
|
||||
dots.innerHTML = "";
|
||||
state.demo.steps.forEach(function (_, j) {
|
||||
var d = document.createElement("div");
|
||||
d.className = "dot" + (j === state.i ? " active" : "");
|
||||
dots.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
function next() {
|
||||
var cur = state.demo.steps[state.i];
|
||||
if (state.i >= state.demo.steps.length - 1) {
|
||||
beacon("complete", cur.id);
|
||||
return;
|
||||
}
|
||||
state.i++;
|
||||
beacon("advance", state.demo.steps[state.i].id);
|
||||
render();
|
||||
}
|
||||
function prev() {
|
||||
if (state.i === 0) return;
|
||||
state.i--;
|
||||
beacon("back", state.demo.steps[state.i].id);
|
||||
render();
|
||||
}
|
||||
|
||||
document.getElementById("next").addEventListener("click", next);
|
||||
document.getElementById("prev").addEventListener("click", prev);
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "ArrowRight") next();
|
||||
if (e.key === "ArrowLeft") prev();
|
||||
});
|
||||
|
||||
fetch("/api/public/demo/" + token)
|
||||
.then(function (r) { if (!r.ok) throw new Error("not found"); return r.json(); })
|
||||
.then(function (demo) {
|
||||
state.demo = demo;
|
||||
titleEl.textContent = demo.name;
|
||||
document.title = demo.name;
|
||||
beacon("view", demo.steps.length ? demo.steps[0].id : null);
|
||||
render();
|
||||
})
|
||||
.catch(function () {
|
||||
snap.innerHTML = "<div style='padding:40px;color:#64748b'>This demo is not available.</div>";
|
||||
});
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue