v0.4.0-alpha: duplicate_demo, editor UI for branches/chapters, delete, personalized links

- duplicate: REST + MCP tool + editor button. Deep copy with branch targets
  and chapter targets remapped into the copy; lead gates + raster cache
  carried; copy is always a private draft (no inherited share token).
  Verified: 3 steps / 2 chapters / 1 branch all internally consistent.
- delete demo: REST + editor button; FK cascade verified (0 orphans)
- Editor: draw-mode toggle (hotspot vs branch) with target-step picker and
  branch list; chapters manager panel; personalized-link generator;
  duplicate/delete on demo list
- MCP surface: 16 tools
- USER-GUIDE updated: new editor features + personalization prompt now uses
  duplicate_demo
This commit is contained in:
Scot Thom 2026-07-18 06:35:48 +00:00
parent 4333cd23ef
commit 3806fa917b
6 changed files with 265 additions and 26 deletions

View file

@ -165,6 +165,55 @@ function buildServer(key: AuthedKey): McpServer {
}, },
); );
s.tool(
"duplicate_demo",
"Duplicate a demo as a new draft ('Copy of ...'): all steps, hotspots, annotations, lead gates, branches (retargeted), and chapters (retargeted). Assets are shared. Use before personalizing for a specific prospect.",
{ demo_id: z.string().uuid() },
async ({ demo_id }) => {
const d = await pool.query("SELECT * FROM demos WHERE id=$1", [demo_id]);
if (!d.rowCount) return text({ error: "not found" });
const src = d.rows[0];
const steps = await pool.query("SELECT * FROM steps WHERE demo_id=$1 ORDER BY position", [demo_id]);
await pool.query("BEGIN");
try {
const nd = await pool.query(
"INSERT INTO demos (name, capture_id, theme) VALUES ($1,$2,$3) RETURNING id",
[`Copy of ${src.name}`.slice(0, 200), src.capture_id, src.theme],
);
const newId = nd.rows[0].id;
const idMap = new Map<string, string>();
for (const st of steps.rows) {
const ns = await pool.query(
`INSERT INTO steps (demo_id, position, snapshot_type, asset_id, dom_html, hotspot, annotation, lead_gate, raster_asset_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
[newId, st.position, st.snapshot_type, st.asset_id, st.dom_html, st.hotspot, st.annotation, st.lead_gate, st.raster_asset_id],
);
idMap.set(st.id, ns.rows[0].id);
}
for (const st of steps.rows) {
if (!st.branches) continue;
const remapped = (st.branches as { target_step_id: string }[])
.map((b) => ({ ...b, target_step_id: idMap.get(b.target_step_id) }))
.filter((b) => b.target_step_id);
await pool.query("UPDATE steps SET branches=$2 WHERE id=$1", [
idMap.get(st.id), remapped.length ? JSON.stringify(remapped) : null,
]);
}
if (src.chapters) {
const ch = (src.chapters as { title: string; step_id: string }[])
.map((c) => ({ ...c, step_id: idMap.get(c.step_id) }))
.filter((c) => c.step_id);
await pool.query("UPDATE demos SET chapters=$2 WHERE id=$1", [newId, ch.length ? JSON.stringify(ch) : null]);
}
await pool.query("COMMIT");
return text({ id: newId, name: `Copy of ${src.name}`, steps: steps.rowCount });
} catch (e) {
await pool.query("ROLLBACK");
throw e;
}
},
);
s.tool( s.tool(
"set_step_branches", "set_step_branches",
"Set branch hotspots on a step: extra clickable regions that jump to a target step (percent coordinates). Pass an empty array to clear.", "Set branch hotspots on a step: extra clickable regions that jump to a target step (percent coordinates). Pass an empty array to clear.",

View file

@ -144,6 +144,60 @@ export async function demoRoutes(app: FastifyInstance): Promise<void> {
return { ok: true }; return { ok: true };
}); });
app.post("/api/demos/:id/duplicate", { preHandler: requireScope("author") }, async (req, reply) => {
const { id } = req.params as { id: string };
const d = await pool.query("SELECT * FROM demos WHERE id=$1", [id]);
if (!d.rowCount) return reply.code(404).send({ error: "not found" });
const src = d.rows[0];
const steps = await pool.query("SELECT * FROM steps WHERE demo_id=$1 ORDER BY position", [id]);
await pool.query("BEGIN");
try {
const nd = await pool.query(
"INSERT INTO demos (name, capture_id, theme) VALUES ($1,$2,$3) RETURNING id",
[`Copy of ${src.name}`.slice(0, 200), src.capture_id, src.theme],
);
const newId = nd.rows[0].id;
const idMap = new Map<string, string>();
for (const st of steps.rows) {
const ns = await pool.query(
`INSERT INTO steps (demo_id, position, snapshot_type, asset_id, dom_html, hotspot, annotation, lead_gate, raster_asset_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
[newId, st.position, st.snapshot_type, st.asset_id, st.dom_html, st.hotspot, st.annotation, st.lead_gate, st.raster_asset_id],
);
idMap.set(st.id, ns.rows[0].id);
}
// second pass: remap branch targets
for (const st of steps.rows) {
if (!st.branches) continue;
const remapped = (st.branches as { target_step_id: string }[])
.map((b) => ({ ...b, target_step_id: idMap.get(b.target_step_id) }))
.filter((b) => b.target_step_id);
await pool.query("UPDATE steps SET branches=$2 WHERE id=$1", [
idMap.get(st.id), remapped.length ? JSON.stringify(remapped) : null,
]);
}
// remap chapters
if (src.chapters) {
const ch = (src.chapters as { title: string; step_id: string }[])
.map((c) => ({ ...c, step_id: idMap.get(c.step_id) }))
.filter((c) => c.step_id);
await pool.query("UPDATE demos SET chapters=$2 WHERE id=$1", [newId, ch.length ? JSON.stringify(ch) : null]);
}
await pool.query("COMMIT");
return { id: newId, name: `Copy of ${src.name}`.slice(0, 200), status: "draft", steps: steps.rowCount };
} catch (e) {
await pool.query("ROLLBACK");
throw e;
}
});
app.delete("/api/demos/:id", { preHandler: requireScope("author") }, async (req) => {
const { id } = req.params as { id: string };
await pool.query("DELETE FROM demos WHERE id=$1", [id]); // steps/analytics/leads cascade
return { ok: true };
});
app.post("/api/demos/:id/reorder", { preHandler: requireScope("author") }, async (req, reply) => { app.post("/api/demos/:id/reorder", { preHandler: requireScope("author") }, async (req, reply) => {
const { id } = req.params as { id: string }; const { id } = req.params as { id: string };
const body = z.object({ ordered_step_ids: z.array(z.string().uuid()).min(1) }).parse(req.body); const body = z.object({ ordered_step_ids: z.array(z.string().uuid()).min(1) }).parse(req.body);

View file

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { api, getKey, setKey, type Demo, type Step, type Hotspot } from "./api"; import { api, getKey, setKey, type Demo, type Step, type Hotspot, type Branch, type Chapter } from "./api";
export function App(): React.ReactElement { export function App(): React.ReactElement {
const [key, setKeyState] = useState(getKey()); const [key, setKeyState] = useState(getKey());
@ -17,7 +17,7 @@ export function App(): React.ReactElement {
}, [key, refreshList]); }, [key, refreshList]);
const openDemo = (id: string) => const openDemo = (id: string) =>
api.getDemo(id).then((d) => { setCurrent(d); setStepIdx(0); setErr(""); }).catch((e) => setErr(String(e))); api.getDemo(id).then((d) => { setCurrent(d); setStepIdx((i) => Math.min(i, Math.max(0, d.steps.length - 1))); setErr(""); }).catch((e) => setErr(String(e)));
const reload = () => current && openDemo(current.id); const reload = () => current && openDemo(current.id);
@ -47,6 +47,9 @@ export function App(): React.ReactElement {
<strong>{d.name}</strong> <strong>{d.name}</strong>
<span className={`badge ${d.status}`}>{d.status}</span> <span className={`badge ${d.status}`}>{d.status}</span>
{d.share_token && <a href={`/p/${d.share_token}`} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>view </a>} {d.share_token && <a href={`/p/${d.share_token}`} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>view </a>}
<span className="spacer" />
<button className="x" title="Duplicate" onClick={(e) => { e.stopPropagation(); api.duplicate(d.id).then(refreshList).catch((er) => setErr(String(er))); }}></button>
<button className="x" title="Delete demo" onClick={(e) => { e.stopPropagation(); if (confirm(`Delete "${d.name}" and all its steps, analytics and leads?`)) api.deleteDemo(d.id).then(refreshList).catch((er) => setErr(String(er))); }}>×</button>
</li> </li>
))} ))}
{!demos.length && <li className="empty">No demos yet create one, then add screenshots as steps (or use the capture extension).</li>} {!demos.length && <li className="empty">No demos yet create one, then add screenshots as steps (or use the capture extension).</li>}
@ -81,6 +84,9 @@ export function App(): React.ReactElement {
reload(); reload();
}} /> }} />
</label> </label>
<ChaptersPanel demo={current} onSaved={reload} />
<button className="publish" onClick={() => api.publish(current.id).then(({ share_token }) => { alert(`Published: ${location.origin}/p/${share_token}`); reload(); })}> <button className="publish" onClick={() => api.publish(current.id).then(({ share_token }) => { alert(`Published: ${location.origin}/p/${share_token}`); reload(); })}>
{current.status === "published" ? "Republish" : "Publish"} {current.status === "published" ? "Republish" : "Publish"}
</button> </button>
@ -92,25 +98,80 @@ export function App(): React.ReactElement {
navigator.clipboard.writeText(code); alert("Embed code copied:\n\n" + code); navigator.clipboard.writeText(code); alert("Embed code copied:\n\n" + code);
}}>Copy embed code</button> }}>Copy embed code</button>
)} )}
{current.share_token && (
<button onClick={() => {
const v = prompt("Viewer token for this recipient (e.g. acme-corp):");
if (!v) return;
const link = `${location.origin}/p/${current.share_token}?v=${encodeURIComponent(v.trim())}`;
navigator.clipboard.writeText(link); alert("Personalized link copied:\n\n" + link);
}}>Copy personalized link</button>
)}
<button onClick={() => api.renderVideo(current.id).then((r) => alert(`Video rendered${r.skipped_dom_steps ? ` (${r.skipped_dom_steps} DOM steps skipped)` : ""}. Download: ${location.origin}${r.download}`)).catch((e) => alert(String(e)))}>Export video</button> <button onClick={() => api.renderVideo(current.id).then((r) => alert(`Video rendered${r.skipped_dom_steps ? ` (${r.skipped_dom_steps} DOM steps skipped)` : ""}. Download: ${location.origin}${r.download}`)).catch((e) => alert(String(e)))}>Export video</button>
</aside> </aside>
<main> <main>
{err && <div className="err">{err}</div>} {err && <div className="err">{err}</div>}
{step ? <StepCanvas step={step} onSaved={reload} /> : <div className="empty">Add a step to begin.</div>} {step ? <StepCanvas step={step} allSteps={current.steps} onSaved={reload} /> : <div className="empty">Add a step to begin.</div>}
</main> </main>
</div> </div>
); );
} }
function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): React.ReactElement { function ChaptersPanel({ demo, onSaved }: { demo: Demo & { steps: Step[] }; onSaved: () => void }): React.ReactElement {
const [chapters, setChapters] = useState<Chapter[]>(demo.chapters ?? []);
const [title, setTitle] = useState("");
const [target, setTarget] = useState(demo.steps[0]?.id ?? "");
useEffect(() => { setChapters(demo.chapters ?? []); }, [demo.id, demo.chapters]);
const save = (next: Chapter[]) => {
setChapters(next);
api.patchDemo(demo.id, { chapters: next.length ? next : null }).then(onSaved);
};
const stepNo = (id: string) => demo.steps.findIndex((s) => s.id === id) + 1;
return (
<details className="panel">
<summary>Chapters ({chapters.length})</summary>
<ul className="chapter-list">
{chapters.map((c, i) => (
<li key={i}>
<span>{c.title} Step {stepNo(c.step_id) || "?"}</span>
<button className="x" onClick={() => save(chapters.filter((_, j) => j !== i))}>×</button>
</li>
))}
</ul>
<div className="chapter-add">
<input placeholder="Chapter title" value={title} onChange={(e) => setTitle(e.target.value)} />
<select value={target} onChange={(e) => setTarget(e.target.value)}>
{demo.steps.map((s, i) => <option key={s.id} value={s.id}>Step {i + 1}</option>)}
</select>
<button disabled={!title.trim() || !target} onClick={() => { save([...chapters, { title: title.trim(), step_id: target }]); setTitle(""); }}>Add</button>
</div>
</details>
);
}
type DrawMode = "hotspot" | "branch";
function StepCanvas({ step, allSteps, onSaved }: { step: Step; allSteps: Step[]; onSaved: () => void }): React.ReactElement {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [mode, setMode] = useState<DrawMode>("hotspot");
const [drag, setDrag] = useState<{ x: number; y: number } | null>(null); const [drag, setDrag] = useState<{ x: number; y: number } | null>(null);
const [box, setBox] = useState<Hotspot | null>(step.hotspot); const [box, setBox] = useState<Hotspot | null>(step.hotspot);
const [branches, setBranches] = useState<Branch[]>(step.branches ?? []);
const [pendingBranch, setPendingBranch] = useState<Hotspot | null>(null);
const [branchTarget, setBranchTarget] = useState("");
const [branchLabel, setBranchLabel] = useState("");
const [title, setTitle] = useState(step.annotation?.title ?? ""); const [title, setTitle] = useState(step.annotation?.title ?? "");
const [body, setBody] = useState(step.annotation?.body ?? ""); const [body, setBody] = useState(step.annotation?.body ?? "");
const [gated, setGated] = useState(!!step.lead_gate?.enabled); const [gated, setGated] = useState(!!step.lead_gate?.enabled);
useEffect(() => { setBox(step.hotspot); setTitle(step.annotation?.title ?? ""); setBody(step.annotation?.body ?? ""); setGated(!!step.lead_gate?.enabled); }, [step.id]); useEffect(() => {
setBox(step.hotspot); setBranches(step.branches ?? []); setPendingBranch(null);
setTitle(step.annotation?.title ?? ""); setBody(step.annotation?.body ?? "");
setGated(!!step.lead_gate?.enabled); setMode("hotspot");
}, [step.id]);
const pct = (e: React.MouseEvent): { x: number; y: number } => { const pct = (e: React.MouseEvent): { x: number; y: number } => {
const r = ref.current!.getBoundingClientRect(); const r = ref.current!.getBoundingClientRect();
@ -120,9 +181,27 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea
}; };
}; };
const stepNo = (id: string) => allSteps.findIndex((s) => s.id === id) + 1;
const otherSteps = allSteps.filter((s) => s.id !== step.id);
const saveStep = () =>
api.patchStep(step.id, {
hotspot: box,
annotation: title || body ? { title, body } : null,
branches: branches.length ? branches : null,
lead_gate: gated ? { enabled: true, fields: [
{ name: "name", label: "Your name", required: true },
{ name: "email", label: "Work email", required: true },
] } : null,
}).then(onSaved);
return ( return (
<div className="canvas-wrap"> <div className="canvas-wrap">
<p className="hint">Drag on the snapshot to draw the hotspot. Click Save when done.</p> <div className="mode-row">
<span className="hint">Draw:</span>
<label><input type="radio" checked={mode === "hotspot"} onChange={() => setMode("hotspot")} /> Next-step hotspot</label>
<label><input type="radio" checked={mode === "branch"} onChange={() => setMode("branch")} /> Branch hotspot</label>
</div>
<div <div
ref={ref} ref={ref}
className="canvas" className="canvas"
@ -130,12 +209,13 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea
onMouseMove={(e) => { onMouseMove={(e) => {
if (!drag) return; if (!drag) return;
const p = pct(e); const p = pct(e);
setBox({ const b = {
x: Math.min(drag.x, p.x), x: Math.min(drag.x, p.x),
y: Math.min(drag.y, p.y), y: Math.min(drag.y, p.y),
w: Math.abs(p.x - drag.x), w: Math.abs(p.x - drag.x),
h: Math.abs(p.y - drag.y), h: Math.abs(p.y - drag.y),
}); };
if (mode === "hotspot") setBox(b); else setPendingBranch(b);
}} }}
onMouseUp={() => setDrag(null)} onMouseUp={() => setDrag(null)}
> >
@ -144,25 +224,51 @@ function StepCanvas({ step, onSaved }: { step: Step; onSaved: () => void }): Rea
) : ( ) : (
<div className="dom-note">DOM step (rendered in player)</div> <div className="dom-note">DOM step (rendered in player)</div>
)} )}
{box && ( {box && <div className="hotspot" style={{ left: `${box.x}%`, top: `${box.y}%`, width: `${box.w}%`, height: `${box.h}%` }} />}
<div className="hotspot" style={{ left: `${box.x}%`, top: `${box.y}%`, width: `${box.w}%`, height: `${box.h}%` }} /> {branches.map((b, i) => (
<div key={i} className="hotspot branch" title={b.label} style={{ left: `${b.x}%`, top: `${b.y}%`, width: `${b.w}%`, height: `${b.h}%` }} />
))}
{pendingBranch && <div className="hotspot branch pending" style={{ left: `${pendingBranch.x}%`, top: `${pendingBranch.y}%`, width: `${pendingBranch.w}%`, height: `${pendingBranch.h}%` }} />}
</div>
{mode === "branch" && (
<div className="branch-form">
{pendingBranch ? (
<>
<input placeholder="Label (optional)" value={branchLabel} onChange={(e) => setBranchLabel(e.target.value)} />
<select value={branchTarget} onChange={(e) => setBranchTarget(e.target.value)}>
<option value="">Jump to</option>
{otherSteps.map((s) => <option key={s.id} value={s.id}>Step {stepNo(s.id)}</option>)}
</select>
<button disabled={!branchTarget} onClick={() => {
setBranches([...branches, { ...pendingBranch, label: branchLabel.trim() || undefined, target_step_id: branchTarget }]);
setPendingBranch(null); setBranchLabel(""); setBranchTarget("");
}}>Add branch</button>
<button className="ghost" onClick={() => setPendingBranch(null)}>Cancel</button>
</>
) : (
<span className="hint">Drag on the snapshot to draw a branch region, then pick its target step.</span>
)} )}
</div> </div>
)}
{branches.length > 0 && (
<ul className="branch-list">
{branches.map((b, i) => (
<li key={i}>
<span>{b.label || "Branch"} Step {stepNo(b.target_step_id) || "?"}</span>
<button className="x" onClick={() => setBranches(branches.filter((_, j) => j !== i))}>×</button>
</li>
))}
</ul>
)}
<div className="ann"> <div className="ann">
<input placeholder="Tooltip title" value={title} onChange={(e) => setTitle(e.target.value)} /> <input placeholder="Tooltip title" value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea placeholder="Tooltip body" value={body} onChange={(e) => setBody(e.target.value)} rows={3} /> <textarea placeholder="Tooltip body" value={body} onChange={(e) => setBody(e.target.value)} rows={3} />
<label className="gate"><input type="checkbox" checked={gated} onChange={(e) => setGated(e.target.checked)} /> Lead gate: require name + email before advancing past this step</label> <label className="gate"><input type="checkbox" checked={gated} onChange={(e) => setGated(e.target.checked)} /> Lead gate: require name + email before advancing past this step</label>
<div className="row"> <div className="row">
<button onClick={() => <button onClick={saveStep}>Save step</button>
api.patchStep(step.id, {
hotspot: box,
annotation: title || body ? { title, body } : null,
lead_gate: gated ? { enabled: true, fields: [
{ name: "name", label: "Your name", required: true },
{ name: "email", label: "Work email", required: true },
] } : null,
}).then(onSaved)
}>Save step</button>
<button className="ghost" onClick={() => setBox(null)}>Clear hotspot</button> <button className="ghost" onClick={() => setBox(null)}>Clear hotspot</button>
</div> </div>
</div> </div>

View file

@ -20,6 +20,8 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
export interface Hotspot { x: number; y: number; w: number; h: number; } export interface Hotspot { x: number; y: number; w: number; h: number; }
export interface Annotation { title: string; body: string; } export interface Annotation { title: string; body: string; }
export interface Branch { x: number; y: number; w: number; h: number; label?: string; target_step_id: string; }
export interface Chapter { title: string; step_id: string; }
export interface LeadGate { enabled: boolean; fields: { name: string; label: string; required: boolean }[]; } export interface LeadGate { enabled: boolean; fields: { name: string; label: string; required: boolean }[]; }
export interface Step { export interface Step {
id: string; id: string;
@ -29,12 +31,14 @@ export interface Step {
hotspot: Hotspot | null; hotspot: Hotspot | null;
annotation: Annotation | null; annotation: Annotation | null;
lead_gate?: LeadGate | null; lead_gate?: LeadGate | null;
branches?: Branch[] | null;
} }
export interface Demo { export interface Demo {
id: string; id: string;
name: string; name: string;
status: "draft" | "published"; status: "draft" | "published";
share_token: string | null; share_token: string | null;
chapters?: Chapter[] | null;
steps?: Step[]; steps?: Step[];
} }
@ -42,10 +46,14 @@ export const api = {
listDemos: () => req<Demo[]>("GET", "/api/demos"), listDemos: () => req<Demo[]>("GET", "/api/demos"),
getDemo: (id: string) => req<Demo & { steps: Step[] }>("GET", `/api/demos/${id}`), getDemo: (id: string) => req<Demo & { steps: Step[] }>("GET", `/api/demos/${id}`),
createDemo: (name: string) => req<Demo>("POST", "/api/demos", { name }), createDemo: (name: string) => req<Demo>("POST", "/api/demos", { name }),
patchStep: (id: string, patch: Partial<Pick<Step, "hotspot" | "annotation" | "position" | "lead_gate">>) => patchStep: (id: string, patch: Partial<Pick<Step, "hotspot" | "annotation" | "position" | "lead_gate" | "branches">>) =>
req<Step>("PATCH", `/api/steps/${id}`, patch), req<Step>("PATCH", `/api/steps/${id}`, patch),
deleteStep: (id: string) => req<{ ok: boolean }>("DELETE", `/api/steps/${id}`), deleteStep: (id: string) => req<{ ok: boolean }>("DELETE", `/api/steps/${id}`),
publish: (id: string) => req<{ share_token: string; url: string }>("POST", `/api/demos/${id}/publish`), publish: (id: string) => req<{ share_token: string; url: string }>("POST", `/api/demos/${id}/publish`),
patchDemo: (id: string, patch: { name?: string; chapters?: Chapter[] | null }) =>
req<Demo>("PATCH", `/api/demos/${id}`, patch),
duplicate: (id: string) => req<{ id: string; name: string }>("POST", `/api/demos/${id}/duplicate`, {}),
deleteDemo: (id: string) => req<{ ok: boolean }>("DELETE", `/api/demos/${id}`),
reorder: (id: string, ids: string[]) => req<{ ok: boolean }>("POST", `/api/demos/${id}/reorder`, { ordered_step_ids: ids }), reorder: (id: string, ids: string[]) => req<{ ok: boolean }>("POST", `/api/demos/${id}/reorder`, { ordered_step_ids: ids }),
renderVideo: (id: string) => req<{ asset_id: string; download: string; skipped_dom_steps: number }>("POST", `/api/demos/${id}/video`, {}), renderVideo: (id: string) => req<{ asset_id: string; download: string; skipped_dom_steps: number }>("POST", `/api/demos/${id}/video`, {}),
addImageStep: async (demoId: string, file: File): Promise<Step> => { addImageStep: async (demoId: string, file: File): Promise<Step> => {

View file

@ -42,3 +42,15 @@ input,textarea { font:inherit; border:1px solid var(--line); border-radius:6px;
.mini .x[disabled] { opacity:.3; cursor:default; } .mini .x[disabled] { opacity:.3; cursor:default; }
.gate { font-size:13px; color:var(--muted); display:flex; gap:8px; align-items:center; } .gate { font-size:13px; color:var(--muted); display:flex; gap:8px; align-items:center; }
.gate input { width:auto; } .gate input { width:auto; }
.spacer { flex:1; }
.hotspot.branch { border-style:dashed; background:rgba(37,99,235,.05); }
.hotspot.branch.pending { border-color:#d97706; }
.mode-row { display:flex; gap:14px; align-items:center; margin-bottom:8px; font-size:13px; color:var(--muted); }
.mode-row input { width:auto; }
.branch-form { display:flex; gap:8px; margin-top:10px; align-items:center; flex-wrap:wrap; }
.branch-form input, .branch-form select { width:auto; flex:1; min-width:120px; }
.branch-list, .chapter-list { list-style:none; padding:0; margin:8px 0 0; display:flex; flex-direction:column; gap:4px; font-size:13px; }
.branch-list li, .chapter-list li { display:flex; justify-content:space-between; align-items:center; background:#f8fafc; border:1px solid var(--line); border-radius:6px; padding:5px 10px; }
.panel { border:1px solid var(--line); border-radius:6px; padding:8px 10px; }
.panel summary { cursor:pointer; font-size:13px; color:var(--muted); }
.chapter-add { display:flex; flex-direction:column; gap:6px; margin-top:8px; }

View file

@ -76,6 +76,15 @@ are the last line of defence.
- **Lead gate** — tick the box on a step and viewers must leave name + email - **Lead gate** — tick the box on a step and viewers must leave name + email
before advancing past it. Put it after you've shown value, not on step 1 before advancing past it. Put it after you've shown value, not on step 1
- **Reorder / delete** — arrows and × in the step list - **Reorder / delete** — arrows and × in the step list
- **Branch hotspots** — switch Draw mode to "Branch", drag a region, pick the
target step. Branches render dashed in the player and let viewers choose
their own path
- **Chapters** — the Chapters panel in the sidebar builds the jump menu shown
in the player bar
- **Duplicate / delete demos** — ⧉ and × on the demo list. Duplicate before
personalizing for a prospect; the copy is always a private draft
- **Personalized link** — one click generates a share link with a viewer token
baked in
- **Step guide** — auto-generated from your steps at `/p/<token>/guide` - **Step guide** — auto-generated from your steps at `/p/<token>/guide`
- **Export video** — renders an MP4 slideshow of all steps; add narration via - **Export video** — renders an MP4 slideshow of all steps; add narration via
your agent (§7, "Narrated video") your agent (§7, "Narrated video")
@ -138,10 +147,11 @@ the agent can list them.
> "Deep dive", "Getting started". > "Deep dive", "Getting started".
**Personalized prospect demo:** **Personalized prospect demo:**
> Duplicate isn't available yet, so: take demo "Generic Tour" and rewrite all > Duplicate the demo "Generic Tour", then rewrite all tooltips in the copy to
> tooltips to speak directly to Acme Corp (logistics company, 400 trucks, > speak directly to Acme Corp (logistics company, 400 trucks, pain point is
> pain point is manual dispatch). Mention their industry where natural. Give > manual dispatch). Mention their industry where natural. Rename the copy
> me the personalized share link format with viewer token acme-corp. > "Acme Corp Tour" and tell me when it's ready to publish — I'll send it with
> viewer token acme-corp.
**Branching demo (choose-your-own path):** **Branching demo (choose-your-own path):**
> On demo "Full Product Tour": add branch hotspots on step 1 — one labeled > On demo "Full Product Tour": add branch hotspots on step 1 — one labeled