v0.2.0-alpha: DOM capture, all three output slices, lead gates

- Extension DOM mode: auth-context serializer — inlines CSS + images as data
  URLs, freezes form state, masks [data-dp-mask], strips scripts (R3 client)
- API: extractDataUrls rewrites large inline resources into content-addressed
  assets (R3 server; verified 939KB inline -> 82-byte row + served asset)
- Lead-gate steps: config on step, player overlay form blocks advance,
  submits to leads endpoint (editor toggle + MCP set_lead_gate)
- Step-guide export (v2 slice): authed markdown + public HTML at /p/:token/guide
- Video export (v3 slice, v0): ffmpeg slideshow of image steps + optional
  narration mux; DOM rasterization deferred to Chromium service (R4)
- Editor: reorder arrows, embed-code copy, guide link, video export
- REST reorder endpoint; MCP surface now 13 tools (export_guide,
  set_lead_gate, render_video)
- ffmpeg added to runtime image

E2E verified incl. R3 extraction round-trip, lead_gate in public JSON,
guide renders, MCP agent exported guide + rendered video.
This commit is contained in:
Scot Thom 2026-07-18 05:27:58 +00:00
parent 9b09f14916
commit a535e15253
18 changed files with 541 additions and 22 deletions

110
extension/dom-content.js Normal file
View file

@ -0,0 +1,110 @@
// DOM-mode capture: on each click, serialize the live document into a
// self-contained HTML snapshot (R3 client half — runs inside the user's auth
// context so protected images/styles resolve). Scripts stripped; form state
// frozen; [data-dp-mask] elements masked.
(function () {
if (window.__dpDomCaptureInstalled) return;
window.__dpDomCaptureInstalled = true;
var busy = Promise.resolve();
async function toDataUrl(url) {
try {
var res = await fetch(url, { credentials: "include" });
if (!res.ok) return null;
var blob = await res.blob();
if (blob.size > 4 * 1024 * 1024) return null; // cap single asset at 4MB
return await new Promise(function (resolve) {
var r = new FileReader();
r.onload = function () { resolve(r.result); };
r.onerror = function () { resolve(null); };
r.readAsDataURL(blob);
});
} catch (_) { return null; }
}
async function collectCss() {
var parts = [];
for (var i = 0; i < document.styleSheets.length; i++) {
var sheet = document.styleSheets[i];
try {
var rules = sheet.cssRules;
var buf = [];
for (var j = 0; j < rules.length; j++) buf.push(rules[j].cssText);
parts.push(buf.join("\n"));
} catch (_) {
// cross-origin: fetch the stylesheet ourselves
if (sheet.href) {
try {
var res = await fetch(sheet.href, { credentials: "include" });
if (res.ok) parts.push(await res.text());
} catch (_) { /* skip */ }
}
}
}
return parts.join("\n\n");
}
async function serialize() {
var root = document.documentElement.cloneNode(true);
// strip live/script-ish elements
root.querySelectorAll("script,noscript,iframe,link[rel=stylesheet],style,object,embed").forEach(function (el) { el.remove(); });
// mask sensitive elements
root.querySelectorAll("[data-dp-mask]").forEach(function (el) { el.textContent = "•••••"; });
// freeze form state from the LIVE dom onto the clone (index-aligned)
var liveInputs = document.querySelectorAll("input,textarea,select");
var cloneInputs = root.querySelectorAll("input,textarea,select");
liveInputs.forEach(function (live, i) {
var c = cloneInputs[i];
if (!c) return;
if (live.tagName === "TEXTAREA") c.textContent = live.value;
else if (live.tagName === "SELECT") {
Array.prototype.forEach.call(c.options, function (o, k) { if (k === live.selectedIndex) o.setAttribute("selected", ""); else o.removeAttribute("selected"); });
} else {
if (live.type === "checkbox" || live.type === "radio") { if (live.checked) c.setAttribute("checked", ""); else c.removeAttribute("checked"); }
else if (live.type === "password") c.setAttribute("value", "•••••");
else c.setAttribute("value", live.value);
}
});
// inline images from the LIVE dom (auth context), index-aligned
var liveImgs = document.querySelectorAll("img");
var cloneImgs = root.querySelectorAll("img");
for (var i = 0; i < liveImgs.length; i++) {
var src = liveImgs[i].currentSrc || liveImgs[i].src;
if (!src || src.startsWith("data:")) continue;
var d = await toDataUrl(src);
if (cloneImgs[i]) {
if (d) { cloneImgs[i].setAttribute("src", d); cloneImgs[i].removeAttribute("srcset"); }
else cloneImgs[i].setAttribute("src", src); // fall back to absolute URL
}
}
var css = await collectCss();
var head = root.querySelector("head") || root.insertBefore(document.createElement("head"), root.firstChild);
var style = document.createElement("style");
style.textContent = css;
head.appendChild(style);
var base = document.createElement("base");
base.setAttribute("href", location.origin);
head.insertBefore(base, head.firstChild);
return "<!doctype html>\n" + root.outerHTML;
}
document.addEventListener(
"click",
function (e) {
var xPct = (e.clientX / window.innerWidth) * 100;
var yPct = (e.clientY / window.innerHeight) * 100;
busy = busy.then(async function () {
var html = await serialize();
chrome.runtime.sendMessage({ type: "domstep", html: html, xPct: xPct, yPct: yPct });
});
},
true,
);
})();