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
25
extension/background.js
Normal file
25
extension/background.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Screenshot-flow capture: on each click in the recorded tab, grab a screenshot
|
||||
// plus the click position, building image Steps with pre-placed hotspots.
|
||||
let recording = { active: false, tabId: null, steps: [] };
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
if (msg.type === "start") {
|
||||
recording = { active: true, tabId: msg.tabId, steps: [] };
|
||||
chrome.scripting.executeScript({ target: { tabId: msg.tabId }, files: ["content.js"] });
|
||||
sendResponse({ ok: true });
|
||||
} else if (msg.type === "click" && recording.active && sender.tab?.id === recording.tabId) {
|
||||
chrome.tabs.captureVisibleTab({ format: "png" }, (dataUrl) => {
|
||||
recording.steps.push({ dataUrl, xPct: msg.xPct, yPct: msg.yPct });
|
||||
sendResponse({ ok: true });
|
||||
});
|
||||
return true; // async
|
||||
} else if (msg.type === "status") {
|
||||
sendResponse({ active: recording.active, count: recording.steps.length });
|
||||
} else if (msg.type === "stop") {
|
||||
recording.active = false;
|
||||
sendResponse({ steps: recording.steps });
|
||||
} else if (msg.type === "discard") {
|
||||
recording = { active: false, tabId: null, steps: [] };
|
||||
sendResponse({ ok: true });
|
||||
}
|
||||
});
|
||||
16
extension/content.js
Normal file
16
extension/content.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Reports click positions as viewport percentages. Injected only while recording.
|
||||
(function () {
|
||||
if (window.__dpCaptureInstalled) return;
|
||||
window.__dpCaptureInstalled = true;
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
chrome.runtime.sendMessage({
|
||||
type: "click",
|
||||
xPct: (e.clientX / window.innerWidth) * 100,
|
||||
yPct: (e.clientY / window.innerHeight) * 100,
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
})();
|
||||
11
extension/manifest.json
Normal file
11
extension/manifest.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Demo Platform Capture",
|
||||
"version": "0.1.0",
|
||||
"description": "Capture click-through product demos. Screenshot mode (v0); DOM mode coming in v1.",
|
||||
"permissions": ["activeTab", "tabs", "storage", "scripting"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"action": { "default_popup": "popup.html", "default_title": "Demo Capture" },
|
||||
"background": { "service_worker": "background.js" },
|
||||
"icons": {}
|
||||
}
|
||||
21
extension/popup.html
Normal file
21
extension/popup.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8">
|
||||
<style>
|
||||
body { font: 13px system-ui, sans-serif; width: 280px; padding: 14px; color: #0f172a; }
|
||||
input { width: 100%; margin: 4px 0 8px; padding: 6px 8px; border: 1px solid #e2e8f0; border-radius: 6px; box-sizing: border-box; }
|
||||
button { width: 100%; margin-top: 6px; padding: 8px; border: 1px solid #e2e8f0; border-radius: 6px; background: #fff; cursor: pointer; }
|
||||
button.primary { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||
#status { color: #64748b; margin: 8px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<label>Platform URL <input id="base" placeholder="https://demo.example.com"></label>
|
||||
<label>API key <input id="key" type="password" placeholder="dp_..."></label>
|
||||
<div id="status">Not recording.</div>
|
||||
<button id="start" class="primary">Start recording this tab</button>
|
||||
<button id="finish" hidden>Finish & upload</button>
|
||||
<button id="discard" hidden>Discard</button>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
67
extension/popup.js
Normal file
67
extension/popup.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
chrome.storage.local.get(["base", "key"], (v) => {
|
||||
if (v.base) $("base").value = v.base;
|
||||
if (v.key) $("key").value = v.key;
|
||||
});
|
||||
const save = () => chrome.storage.local.set({ base: $("base").value.trim(), key: $("key").value.trim() });
|
||||
$("base").addEventListener("change", save);
|
||||
$("key").addEventListener("change", save);
|
||||
|
||||
function refresh() {
|
||||
chrome.runtime.sendMessage({ type: "status" }, (s) => {
|
||||
$("status").textContent = s.active ? `Recording — ${s.count} steps captured. Click through your flow, then Finish.` : "Not recording.";
|
||||
$("start").hidden = s.active;
|
||||
$("finish").hidden = !s.active;
|
||||
$("discard").hidden = !s.active;
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
|
||||
$("start").addEventListener("click", async () => {
|
||||
save();
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
chrome.runtime.sendMessage({ type: "start", tabId: tab.id }, refresh);
|
||||
});
|
||||
|
||||
$("discard").addEventListener("click", () => chrome.runtime.sendMessage({ type: "discard" }, refresh));
|
||||
|
||||
$("finish").addEventListener("click", () => {
|
||||
chrome.runtime.sendMessage({ type: "stop" }, async ({ steps }) => {
|
||||
const base = $("base").value.trim().replace(/\/$/, "");
|
||||
const key = $("key").value.trim();
|
||||
if (!base || !key) { $("status").textContent = "Set platform URL and API key first."; return; }
|
||||
if (!steps.length) { $("status").textContent = "No steps captured."; return; }
|
||||
try {
|
||||
$("status").textContent = "Uploading…";
|
||||
const auth = { Authorization: `Bearer ${key}` };
|
||||
const demo = await (await fetch(`${base}/api/demos`, {
|
||||
method: "POST",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: `Capture ${new Date().toLocaleString()}` }),
|
||||
})).json();
|
||||
for (const s of steps) {
|
||||
const blob = await (await fetch(s.dataUrl)).blob();
|
||||
const fd = new FormData();
|
||||
fd.append("file", blob, "step.png");
|
||||
const asset = await (await fetch(`${base}/api/assets`, { method: "POST", headers: auth, body: fd })).json();
|
||||
const step = await (await fetch(`${base}/api/demos/${demo.id}/steps`, {
|
||||
method: "POST",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ snapshot_type: "image", asset_id: asset.id }),
|
||||
})).json();
|
||||
// pre-place a hotspot centred on the recorded click
|
||||
await fetch(`${base}/api/steps/${step.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...auth, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ hotspot: { x: Math.max(0, s.xPct - 4), y: Math.max(0, s.yPct - 4), w: 8, h: 8 } }),
|
||||
});
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: "discard" }, () => {});
|
||||
$("status").textContent = `Done — demo "${demo.name}" created with ${steps.length} steps. Open the editor to refine.`;
|
||||
refresh();
|
||||
} catch (e) {
|
||||
$("status").textContent = `Upload failed: ${e}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue