wicked-interactive 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +26 -0
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/bin/ensure-siblings.mjs +94 -0
- package/bin/wi-watch.mjs +111 -0
- package/bin/wicked-interactive.js +96 -0
- package/frontend/dist/assets/index-Df5rc-Mm.js +41 -0
- package/frontend/dist/assets/index-Dq_AQHYX.css +1 -0
- package/frontend/dist/index.html +13 -0
- package/package.json +40 -0
- package/src/core/feedback-schema.js +124 -0
- package/src/core/instrument.js +116 -0
- package/src/core/regenerate.js +140 -0
- package/src/core/theme.js +79 -0
- package/src/core/versions.js +109 -0
- package/src/index.js +7 -0
- package/src/service/bus.js +30 -0
- package/src/service/demo.js +411 -0
- package/src/service/export.js +124 -0
- package/src/service/fsstore.js +26 -0
- package/src/service/generation.js +105 -0
- package/src/service/preflight.js +84 -0
- package/src/service/server.js +580 -0
- package/src/service/structural.js +103 -0
- package/src/service/theme-source.js +63 -0
- package/src/service/workspace.js +141 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// versions.js — parent-pointer version manifest (ADR-0008).
|
|
2
|
+
//
|
|
3
|
+
// versions.json:
|
|
4
|
+
// {
|
|
5
|
+
// "head": 2,
|
|
6
|
+
// "versions": [
|
|
7
|
+
// { "version": 0, "parent": null, "feedback_file": null, "html_file": "_v0.html", "created_at": "..." },
|
|
8
|
+
// { "version": 1, "parent": 0, "feedback_file": "_v1.md", "html_file": "_v1.html", "created_at": "..." },
|
|
9
|
+
// { "version": 2, "parent": 0, "feedback_file": "_v2.md", "html_file": "_v2.html", "created_at": "..." } // a fork of v0
|
|
10
|
+
// ]
|
|
11
|
+
// }
|
|
12
|
+
//
|
|
13
|
+
// Monotonic version numbers (a fork's child may be _v7 with parent _v3).
|
|
14
|
+
// Write-once (INV-4): existing entries are never mutated or removed.
|
|
15
|
+
|
|
16
|
+
const now = () => new Date().toISOString();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A fresh manifest seeded with version 0 (the initial build). `kind` distinguishes a
|
|
20
|
+
* demo workspace ("demo") from an ordinary document; it's omitted for plain docs so
|
|
21
|
+
* existing manifests stay byte-identical (listDocs defaults a missing kind to "doc").
|
|
22
|
+
*/
|
|
23
|
+
export function initManifest(htmlFile = "_v0.html", { kind } = {}) {
|
|
24
|
+
return {
|
|
25
|
+
...(kind && kind !== "doc" ? { kind } : {}),
|
|
26
|
+
head: 0,
|
|
27
|
+
versions: [{ version: 0, parent: null, feedback_file: null, html_file: htmlFile, created_at: now() }],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function nextNumber(manifest) {
|
|
32
|
+
return manifest.versions.reduce((max, v) => Math.max(max, v.version), -1) + 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getVersion(manifest, version) {
|
|
36
|
+
return manifest.versions.find((v) => v.version === version) || null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Append a new version whose parent is `parent` (defaults to current head).
|
|
41
|
+
* Returns { manifest, version } — manifest is a new object (no in-place mutation of entries).
|
|
42
|
+
*/
|
|
43
|
+
export function addVersion(manifest, { parent = manifest.head, feedbackFile = null } = {}) {
|
|
44
|
+
if (getVersion(manifest, parent) == null) {
|
|
45
|
+
throw new Error(`addVersion: parent v${parent} does not exist`);
|
|
46
|
+
}
|
|
47
|
+
const version = nextNumber(manifest);
|
|
48
|
+
const entry = {
|
|
49
|
+
version,
|
|
50
|
+
parent,
|
|
51
|
+
feedback_file: feedbackFile,
|
|
52
|
+
html_file: `_v${version}.html`,
|
|
53
|
+
created_at: now(),
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
manifest: { ...manifest, head: version, versions: [...manifest.versions, entry] },
|
|
57
|
+
version,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Record a version with an explicit number (used by the feedback flow, which allocates
|
|
63
|
+
* the number when it writes `_v{n}.md` — before the HTML exists). Write-once: refuses to
|
|
64
|
+
* overwrite an existing version. Advances head to the recorded version.
|
|
65
|
+
*/
|
|
66
|
+
export function recordVersion(manifest, { version, parent, feedbackFile = null }) {
|
|
67
|
+
if (getVersion(manifest, version) != null) throw new Error(`recordVersion: v${version} already exists`);
|
|
68
|
+
if (parent != null && getVersion(manifest, parent) == null) {
|
|
69
|
+
throw new Error(`recordVersion: parent v${parent} does not exist`);
|
|
70
|
+
}
|
|
71
|
+
const entry = {
|
|
72
|
+
version,
|
|
73
|
+
parent,
|
|
74
|
+
feedback_file: feedbackFile,
|
|
75
|
+
html_file: `_v${version}.html`,
|
|
76
|
+
created_at: now(),
|
|
77
|
+
};
|
|
78
|
+
return { manifest: { ...manifest, head: version, versions: [...manifest.versions, entry] }, version };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Next version number that would be allocated (max + 1). */
|
|
82
|
+
export function nextVersionNumber(manifest) {
|
|
83
|
+
return nextNumber(manifest);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Fork from an existing version (AC-21): non-destructive — creates a new head whose
|
|
88
|
+
* parent is `from`; all existing versions remain. "Start again from here".
|
|
89
|
+
*/
|
|
90
|
+
export function fork(manifest, from) {
|
|
91
|
+
if (getVersion(manifest, from) == null) throw new Error(`fork: v${from} does not exist`);
|
|
92
|
+
return addVersion(manifest, { parent: from, feedbackFile: null });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The chain of ancestors from a version back to the root (inclusive), root-first. */
|
|
96
|
+
export function ancestry(manifest, version) {
|
|
97
|
+
const chain = [];
|
|
98
|
+
let v = getVersion(manifest, version);
|
|
99
|
+
while (v) {
|
|
100
|
+
chain.unshift(v.version);
|
|
101
|
+
v = v.parent == null ? null : getVersion(manifest, v.parent);
|
|
102
|
+
}
|
|
103
|
+
return chain;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Every version is reachable (AC-22): no entry is ever removed, so all are listed. */
|
|
107
|
+
export function allVersions(manifest) {
|
|
108
|
+
return [...manifest.versions].sort((a, b) => a.version - b.version);
|
|
109
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// wicked-interactive — public API for the core engine (increment 1).
|
|
2
|
+
// Service, React frontend, LLM structural path, and export layer arrive in later increments.
|
|
3
|
+
|
|
4
|
+
export { instrument, collectWids, DEFAULT_REVIEWABLE } from "./core/instrument.js";
|
|
5
|
+
export { parseFeedback, serializeFeedback, TYPES } from "./core/feedback-schema.js";
|
|
6
|
+
export { regenerate, Inv2Error } from "./core/regenerate.js";
|
|
7
|
+
export { initManifest, addVersion, fork, getVersion, ancestry, allVersions } from "./core/versions.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// bus.js — fire-and-forget wicked-bus emission (ADR-0004).
|
|
2
|
+
//
|
|
3
|
+
// Events are best-effort: if the bus is missing or slow, the user-facing loop must not
|
|
4
|
+
// block or fail (graceful degradation). Emission is detached and non-blocking.
|
|
5
|
+
//
|
|
6
|
+
// The default emitter is injectable so callers (and tests) can substitute a spy.
|
|
7
|
+
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
export const EVENTS = {
|
|
11
|
+
FEEDBACK_RECEIVED: { type: "presentation.feedback.received", subdomain: "feedback" },
|
|
12
|
+
HTML_UPDATED: { type: "presentation.html.updated", subdomain: "html" },
|
|
13
|
+
EXPORT_REQUESTED: { type: "presentation.export.requested", subdomain: "export" },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Real emitter: spawns `npx wicked-bus emit` detached. Never throws. */
|
|
17
|
+
export function busEmit({ type, subdomain }, payload) {
|
|
18
|
+
if (process.env.WICKED_NO_BUS === "1") return;
|
|
19
|
+
try {
|
|
20
|
+
const child = spawn(
|
|
21
|
+
"npx",
|
|
22
|
+
["wicked-bus", "emit", "--type", type, "--domain", "presentation",
|
|
23
|
+
"--subdomain", subdomain, "--payload", JSON.stringify(payload)],
|
|
24
|
+
{ detached: true, stdio: "ignore" },
|
|
25
|
+
);
|
|
26
|
+
child.unref();
|
|
27
|
+
} catch {
|
|
28
|
+
/* graceful degradation — the bus is optional */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// demo.js — the "demo" doc kind (ADR-0018): point wicked-interactive at a running app,
|
|
2
|
+
// the supervising agent learns it and authors a deterministic Playwright spec, and this
|
|
3
|
+
// model-free service EXECUTES that spec and RECORDS it. The recording + an anchored
|
|
4
|
+
// storyboard become a normal version, so the same feedback -> regenerate -> hot-reload
|
|
5
|
+
// loop applies (highlight a step, ask for a change, the agent re-authors the spec, the
|
|
6
|
+
// service re-records — deterministic replay, just like every other version).
|
|
7
|
+
//
|
|
8
|
+
// The split mirrors ADR-0003 (hybrid) and ADR-0010 (model-free delegation):
|
|
9
|
+
// • Agent (intelligence): explores the URL, writes demo.spec.mjs (the steps).
|
|
10
|
+
// • Service (deterministic infra): owns the browser launch, video capture, tracing,
|
|
11
|
+
// artifact paths, versioning. It never decides WHAT to click — only runs the script.
|
|
12
|
+
|
|
13
|
+
import { mkdirSync, readdirSync, renameSync, existsSync, statSync } from "node:fs";
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import { instrument } from "../core/instrument.js";
|
|
18
|
+
import { themed } from "./theme-source.js";
|
|
19
|
+
import { recordVersion, nextVersionNumber } from "../core/versions.js";
|
|
20
|
+
import { atomicWrite, loadManifest, saveManifest } from "./fsstore.js";
|
|
21
|
+
|
|
22
|
+
export const REQUESTS_DIR = "requests";
|
|
23
|
+
export const DEMO_REQUEST = "_demo.request.json";
|
|
24
|
+
export const RECORDINGS_DIR = "recordings";
|
|
25
|
+
// The agent authors this file: a plain ES module exporting `meta` (url, title, steps[])
|
|
26
|
+
// and `async run({ page, step, meta })`. The service supplies page/step; the agent only
|
|
27
|
+
// expresses the click-path. Kept out of the version artifacts (it's the source, not output).
|
|
28
|
+
export const DEMO_SPEC = "demo.spec.mjs";
|
|
29
|
+
|
|
30
|
+
// Escapes the full set so the same helper is safe in both text and attribute (href/src)
|
|
31
|
+
// contexts — the target URL and title are rendered into attributes in storyboard().
|
|
32
|
+
const esc = (s) => String(s ?? "")
|
|
33
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
34
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
35
|
+
|
|
36
|
+
/** Placeholder storyboard shown at v0 while the agent learns the app and authors the spec. */
|
|
37
|
+
export function demoPlaceholder(name, url, brief = "") {
|
|
38
|
+
const title = esc((name || "your demo").replace(/-/g, " "));
|
|
39
|
+
const briefBlock = brief ? `<blockquote>${esc(brief)}</blockquote>` : "";
|
|
40
|
+
return (
|
|
41
|
+
`<section class="wi-demo">` +
|
|
42
|
+
`<h1>Learning ${esc(url)}…</h1>` +
|
|
43
|
+
`<p class="lead">Exploring the app and authoring the click-path for <b>${title}</b>. ` +
|
|
44
|
+
`When the first recording is ready it will appear right here — then highlight any ` +
|
|
45
|
+
`step to refine it.</p>` +
|
|
46
|
+
briefBlock +
|
|
47
|
+
`</section>`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Write the demo work request for a freshly-created demo doc. The agent watches for this
|
|
53
|
+
* file (or the `demo` SSE event), learns the target app, authors demo.spec.mjs, then calls
|
|
54
|
+
* POST /api/demo/record to have the service execute + record it.
|
|
55
|
+
*/
|
|
56
|
+
export function writeDemoRequest(dir, { url, brief = "", documentId = dir }) {
|
|
57
|
+
mkdirSync(join(dir, REQUESTS_DIR), { recursive: true });
|
|
58
|
+
const body = {
|
|
59
|
+
document_id: documentId,
|
|
60
|
+
url: String(url).trim(),
|
|
61
|
+
brief: String(brief).trim(),
|
|
62
|
+
spec_file: DEMO_SPEC,
|
|
63
|
+
ts: new Date().toISOString(),
|
|
64
|
+
};
|
|
65
|
+
atomicWrite(join(dir, REQUESTS_DIR, DEMO_REQUEST), JSON.stringify(body, null, 2));
|
|
66
|
+
return { requestFile: DEMO_REQUEST };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the storyboard HTML for a recorded demo: the embedded video plus an anchored,
|
|
71
|
+
* ordered step list. The step blocks are the feedback targets (data-wid is assigned by
|
|
72
|
+
* instrument() when the version lands), so a user can highlight "step 3" and ask for a
|
|
73
|
+
* change exactly as they would any other block.
|
|
74
|
+
*
|
|
75
|
+
* The video src is a root-absolute path to this doc's locked recording endpoint, so it
|
|
76
|
+
* resolves correctly regardless of which version path the iframe is currently showing.
|
|
77
|
+
*/
|
|
78
|
+
export function storyboard({ documentId, title, url, videoFile, steps = [] }) {
|
|
79
|
+
const rec = (file) => `/d/${documentId}/api/demo/recording/${encodeURIComponent(file)}`;
|
|
80
|
+
const videoSrc = rec(videoFile);
|
|
81
|
+
// YouTube-style chapters: a clickable thumbnail per step that seeks the video to that
|
|
82
|
+
// step's start time (data-seek, wired by the inline script below). The thumbnail is the
|
|
83
|
+
// frame captured at the end of the step (its resulting view); the time is the chapter start.
|
|
84
|
+
const chapters = steps.length
|
|
85
|
+
? `<ol class="wi-demo__chapters">` +
|
|
86
|
+
steps.map((s, i) => {
|
|
87
|
+
const t = Number.isFinite(s.at) ? s.at : 0;
|
|
88
|
+
const thumb = s.thumb
|
|
89
|
+
? `<img src="${rec(s.thumb)}" alt="${esc(s.label)}" loading="lazy">`
|
|
90
|
+
: `<span class="wi-demo__thumb-ph" aria-hidden="true">${i + 1}</span>`;
|
|
91
|
+
return (
|
|
92
|
+
`<li>` +
|
|
93
|
+
`<button class="wi-demo__chapter" type="button" data-seek="${t}" title="Jump to ${esc(s.label)}">` +
|
|
94
|
+
`<span class="wi-demo__thumb">${thumb}<span class="wi-demo__badge">${fmtTime(t)}</span></span>` +
|
|
95
|
+
`<span class="wi-demo__cap"><span class="wi-demo__idx">${i + 1}</span>` +
|
|
96
|
+
`<span class="wi-demo__name">${esc(s.label)}</span></span>` +
|
|
97
|
+
`</button>` +
|
|
98
|
+
`</li>`
|
|
99
|
+
);
|
|
100
|
+
}).join("") +
|
|
101
|
+
`</ol>`
|
|
102
|
+
: `<p class="wi-demo__nosteps">No steps were recorded.</p>`;
|
|
103
|
+
// Self-contained layout: the iframe loads the raw version HTML, so it never sees the
|
|
104
|
+
// app-shell stylesheet. Inline the demo styles here (using theme vars with fallbacks)
|
|
105
|
+
// so the video renders full-width in the iframe AND in exported HTML.
|
|
106
|
+
const style =
|
|
107
|
+
`<style>` +
|
|
108
|
+
`.wi-demo{max-width:920px;margin:0 auto;padding:8px 4px 40px;}` +
|
|
109
|
+
`.wi-demo__head{margin-bottom:18px;}` +
|
|
110
|
+
`.wi-demo__target{color:var(--wi-text-secondary,#64748B);font-size:14px;margin-top:4px;}` +
|
|
111
|
+
`.wi-demo__target a{color:var(--wi-accent,#0891B2);text-decoration:none;}` +
|
|
112
|
+
`.wi-demo__player{margin:0 0 22px;border-radius:8px;overflow:hidden;background:#0b1020;}` +
|
|
113
|
+
`.wi-demo__player video{display:block;width:100%;height:auto;background:#0b1020;}` +
|
|
114
|
+
`.wi-demo__chaptitle{font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--wi-text-secondary,#64748B);margin:0 0 12px;}` +
|
|
115
|
+
`.wi-demo__chapters{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px;}` +
|
|
116
|
+
`.wi-demo__chapter{display:flex;flex-direction:column;text-align:left;width:100%;padding:0;border:1px solid var(--wi-border,#E2E8F0);border-radius:10px;overflow:hidden;background:var(--wi-card-bg,#FFFFFF);color:inherit;font:inherit;cursor:pointer;transition:transform .12s ease,box-shadow .12s ease,border-color .12s ease;}` +
|
|
117
|
+
`.wi-demo__chapter:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,.12);border-color:var(--wi-accent,#0891B2);}` +
|
|
118
|
+
`.wi-demo__chapter:focus-visible{outline:2px solid var(--wi-accent,#0891B2);outline-offset:2px;}` +
|
|
119
|
+
`.wi-demo__thumb{position:relative;display:block;width:100%;aspect-ratio:16/9;background:#0b1020;}` +
|
|
120
|
+
`.wi-demo__thumb img{display:block;width:100%;height:100%;object-fit:cover;}` +
|
|
121
|
+
`.wi-demo__thumb-ph{display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:#475569;font-size:28px;font-weight:700;}` +
|
|
122
|
+
`.wi-demo__badge{position:absolute;right:6px;bottom:6px;background:rgba(11,16,32,.85);color:#fff;font-size:12px;font-variant-numeric:tabular-nums;padding:2px 6px;border-radius:4px;}` +
|
|
123
|
+
`.wi-demo__cap{display:flex;gap:8px;align-items:baseline;padding:10px 12px;}` +
|
|
124
|
+
`.wi-demo__idx{font-size:12px;font-weight:700;color:var(--wi-accent,#0891B2);font-variant-numeric:tabular-nums;}` +
|
|
125
|
+
`.wi-demo__name{font-weight:600;color:var(--wi-text,#1E293B);font-size:14px;line-height:1.3;}` +
|
|
126
|
+
`.wi-demo__nosteps{color:var(--wi-text-secondary,#64748B);font-style:italic;}` +
|
|
127
|
+
`</style>`;
|
|
128
|
+
// Seek wiring: clicking a chapter sets the video to its start time and plays. Kept inline
|
|
129
|
+
// so it travels with the self-contained export. No external deps.
|
|
130
|
+
const script =
|
|
131
|
+
`<script>(function(){` +
|
|
132
|
+
`var v=document.getElementById("wi-demo-video");if(!v)return;` +
|
|
133
|
+
`var cs=document.querySelectorAll(".wi-demo__chapter");` +
|
|
134
|
+
`for(var i=0;i<cs.length;i++){(function(b){b.addEventListener("click",function(){` +
|
|
135
|
+
`var t=parseFloat(b.getAttribute("data-seek"))||0;try{v.currentTime=t;}catch(e){}` +
|
|
136
|
+
`v.play().catch(function(){});` +
|
|
137
|
+
`v.scrollIntoView({behavior:"smooth",block:"start"});` +
|
|
138
|
+
`});})(cs[i]);}` +
|
|
139
|
+
`})();</script>`;
|
|
140
|
+
return (
|
|
141
|
+
`<section class="wi-demo">` +
|
|
142
|
+
style +
|
|
143
|
+
`<header class="wi-demo__head">` +
|
|
144
|
+
`<h1>${esc(title || "Demo")}</h1>` +
|
|
145
|
+
`<p class="wi-demo__target">Recorded against <a href="${esc(url)}" target="_blank" rel="noopener">${esc(url)}</a></p>` +
|
|
146
|
+
`</header>` +
|
|
147
|
+
`<div class="wi-demo__player">` +
|
|
148
|
+
`<video id="wi-demo-video" controls playsinline preload="metadata" src="${videoSrc}"></video>` +
|
|
149
|
+
`</div>` +
|
|
150
|
+
`<p class="wi-demo__chaptitle">Chapters</p>` +
|
|
151
|
+
chapters +
|
|
152
|
+
script +
|
|
153
|
+
`</section>`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function fmtTime(seconds) {
|
|
158
|
+
const s = Math.max(0, Math.round(seconds));
|
|
159
|
+
const m = Math.floor(s / 60);
|
|
160
|
+
return `${m}:${String(s % 60).padStart(2, "0")}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Execute the agent-authored spec with Playwright and record it, landing a new version
|
|
165
|
+
* whose HTML is the storyboard. Deterministic: same spec -> same click-path. The service
|
|
166
|
+
* owns the browser/recording lifecycle; the spec only supplies the steps.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} dir the demo workspace directory
|
|
169
|
+
* @param {object} opts
|
|
170
|
+
* @param {Function} [opts.emit] HTML_UPDATED emitter (so the browser hot-reloads)
|
|
171
|
+
* @param {string} [opts.documentId] doc name (used for the recording URL + events)
|
|
172
|
+
* @param {Function} [opts.onStep] progress callback ({ index, total, label })
|
|
173
|
+
* @param {boolean} [opts.headless] default true
|
|
174
|
+
* @returns {Promise<{version:number, parent:number, video:string, steps:Array}>}
|
|
175
|
+
*/
|
|
176
|
+
export async function recordDemo(dir, opts = {}) {
|
|
177
|
+
const documentId = opts.documentId ?? dir;
|
|
178
|
+
const specPath = join(dir, DEMO_SPEC);
|
|
179
|
+
if (!existsSync(specPath)) throw new Error(`no ${DEMO_SPEC} authored yet — the agent must write the spec before recording`);
|
|
180
|
+
|
|
181
|
+
// Resolve Playwright lazily so the service runs fine without it until a demo is recorded
|
|
182
|
+
// (the install gate, ADR-0016, blocks demo creation until Playwright is present).
|
|
183
|
+
let chromium;
|
|
184
|
+
try {
|
|
185
|
+
({ chromium } = await import("playwright"));
|
|
186
|
+
} catch {
|
|
187
|
+
throw new Error("Playwright is not installed — run `npx playwright install` (the install gate should have caught this)");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Cache-bust the import so a re-authored spec is picked up (ESM caches by URL).
|
|
191
|
+
const spec = await import(`${pathToFileURL(specPath).href}?t=${Date.now()}`);
|
|
192
|
+
const meta = spec.meta || {};
|
|
193
|
+
const url = String(meta.url || "").trim();
|
|
194
|
+
if (typeof spec.run !== "function") throw new Error(`${DEMO_SPEC} must export an async run({ page, step, meta })`);
|
|
195
|
+
|
|
196
|
+
const recDir = join(dir, RECORDINGS_DIR);
|
|
197
|
+
mkdirSync(recDir, { recursive: true });
|
|
198
|
+
const manifest = loadManifest(dir);
|
|
199
|
+
const version = nextVersionNumber(manifest);
|
|
200
|
+
const videoFile = `_v${version}.webm`;
|
|
201
|
+
const traceFile = `_v${version}.trace.zip`;
|
|
202
|
+
|
|
203
|
+
const browser = await chromium.launch({ headless: opts.headless !== false });
|
|
204
|
+
const stepTimings = [];
|
|
205
|
+
const startedAt = Date.now();
|
|
206
|
+
let context;
|
|
207
|
+
try {
|
|
208
|
+
context = await browser.newContext({
|
|
209
|
+
viewport: { width: 1280, height: 720 },
|
|
210
|
+
recordVideo: { dir: recDir, size: { width: 1280, height: 720 } },
|
|
211
|
+
});
|
|
212
|
+
await context.tracing.start({ screenshots: true, snapshots: true });
|
|
213
|
+
const page = await context.newPage();
|
|
214
|
+
|
|
215
|
+
// On-screen narration: the caption is the curated `say` — describe what's HAPPENING and
|
|
216
|
+
// why it matters, NOT the mechanical action. The step `label` drives the storyboard chapter
|
|
217
|
+
// list, not the overlay, so a terse label ("Open the scope") never becomes meaningless
|
|
218
|
+
// on-screen text. No `say` => no caption for that step (not every beat needs words). It's
|
|
219
|
+
// shown BEFORE the action (covers same-page steps) AND re-asserted AFTER it — a step that
|
|
220
|
+
// navigates (page.goto / waitForURL) wipes the injected node, so the post-action re-show is
|
|
221
|
+
// what guarantees it's visible on the settled view. meta.captions:false suppresses all;
|
|
222
|
+
// tune the read-pause with meta.captionHoldMs or per step via { say, holdMs }.
|
|
223
|
+
const captionsOn = meta.captions !== false;
|
|
224
|
+
const defaultHoldMs = Number.isFinite(meta.captionHoldMs) ? Math.max(0, meta.captionHoldMs) : 2500;
|
|
225
|
+
|
|
226
|
+
// `step` annotates a labelled segment so the storyboard can show ordered, timed steps
|
|
227
|
+
// and so a failure points at the exact step. The agent wraps each action in step().
|
|
228
|
+
let index = 0;
|
|
229
|
+
const step = async (label, fn, sopts = {}) => {
|
|
230
|
+
index += 1;
|
|
231
|
+
const at = (Date.now() - startedAt) / 1000; // chapter start
|
|
232
|
+
const entry = { label: String(label), at };
|
|
233
|
+
stepTimings.push(entry);
|
|
234
|
+
opts.onStep?.({ index, label: String(label), at });
|
|
235
|
+
|
|
236
|
+
const say = sopts.say != null ? String(sopts.say).trim() : "";
|
|
237
|
+
const showCap = captionsOn && say.length > 0;
|
|
238
|
+
const hold = Number.isFinite(sopts.holdMs) ? Math.max(0, sopts.holdMs) : defaultHoldMs;
|
|
239
|
+
|
|
240
|
+
if (showCap) await showCaption(page, say); // before the action (same-page steps)
|
|
241
|
+
|
|
242
|
+
if (typeof fn === "function") await fn();
|
|
243
|
+
|
|
244
|
+
// Re-assert after the action (fn may have navigated and wiped the node), then pause so
|
|
245
|
+
// the viewer reads it against the settled, resulting view.
|
|
246
|
+
if (showCap) {
|
|
247
|
+
await showCaption(page, say);
|
|
248
|
+
if (hold > 0) await page.waitForTimeout(hold);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Chapter thumbnail (YouTube-style): capture the step's resulting view after its action.
|
|
252
|
+
// The seek target stays `at` (chapter start); the frame is the post-action state, which
|
|
253
|
+
// reads best as a thumbnail. Clear the caption first so the still is clean. A thumbnail
|
|
254
|
+
// is nice-to-have — never fail over it.
|
|
255
|
+
if (showCap) await clearCaption(page);
|
|
256
|
+
const thumb = `_v${version}.step${String(index).padStart(2, "0")}.png`;
|
|
257
|
+
try {
|
|
258
|
+
await page.screenshot({ path: join(recDir, thumb) });
|
|
259
|
+
entry.thumb = thumb;
|
|
260
|
+
} catch { /* skip thumbnail */ }
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
await spec.run({ page, step, meta });
|
|
264
|
+
|
|
265
|
+
await context.tracing.stop({ path: join(recDir, traceFile) });
|
|
266
|
+
const pageVideo = page.video();
|
|
267
|
+
await page.close();
|
|
268
|
+
await context.close(); // flushes the video to disk
|
|
269
|
+
context = null;
|
|
270
|
+
|
|
271
|
+
// Playwright names the video with a random id; resolve the real path, then rename to
|
|
272
|
+
// our deterministic per-version filename so the storyboard + endpoint are predictable.
|
|
273
|
+
let produced = null;
|
|
274
|
+
try { produced = pageVideo ? await pageVideo.path() : null; } catch { produced = null; }
|
|
275
|
+
if (!produced) produced = newestWebm(recDir, startedAt);
|
|
276
|
+
if (produced && existsSync(produced) && produced !== join(recDir, videoFile)) {
|
|
277
|
+
renameSync(produced, join(recDir, videoFile));
|
|
278
|
+
}
|
|
279
|
+
} finally {
|
|
280
|
+
if (context) { try { await context.close(); } catch { /* already closing */ } }
|
|
281
|
+
await browser.close();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const html = storyboard({
|
|
285
|
+
documentId,
|
|
286
|
+
title: meta.title || documentId,
|
|
287
|
+
url,
|
|
288
|
+
videoFile,
|
|
289
|
+
steps: stepTimings,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
let m = loadManifest(dir);
|
|
293
|
+
const parent = m.head;
|
|
294
|
+
const prepared = themed(instrument(html).html, opts);
|
|
295
|
+
atomicWrite(join(dir, `_v${version}.html`), prepared);
|
|
296
|
+
({ manifest: m } = recordVersion(m, { version, parent, feedbackFile: null }));
|
|
297
|
+
saveManifest(dir, m);
|
|
298
|
+
|
|
299
|
+
opts.emit?.("HTML_UPDATED", {
|
|
300
|
+
document_id: documentId,
|
|
301
|
+
version, html_file: `_v${version}.html`, prev_version: parent, ts: new Date().toISOString(),
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
return { version, parent, video: videoFile, steps: stepTimings };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- Narration captions -------------------------------------------------------------------
|
|
308
|
+
// A full-width lower-third BAR injected into the live page so it's captured by recordVideo and
|
|
309
|
+
// burned into the .webm. A bold blue bar (not a subtle pill) so the narration stands out over
|
|
310
|
+
// any page rather than blending in. pointer-events:none keeps clicks passing through; a fixed
|
|
311
|
+
// max z-index keeps it above app UI. The node is wiped by navigation — that's fine, it's
|
|
312
|
+
// re-created on the next showCaption(). Best-effort: a mid-navigation page may reject
|
|
313
|
+
// evaluate(), so a failed caption never fails the recording.
|
|
314
|
+
const CAPTION_ID = "__wi_caption__";
|
|
315
|
+
|
|
316
|
+
async function showCaption(page, text) {
|
|
317
|
+
try {
|
|
318
|
+
await page.evaluate(({ id, text }) => {
|
|
319
|
+
let bar = document.getElementById(id);
|
|
320
|
+
if (!bar) {
|
|
321
|
+
bar = document.createElement("div");
|
|
322
|
+
bar.id = id;
|
|
323
|
+
bar.setAttribute("aria-hidden", "true");
|
|
324
|
+
bar.style.cssText =
|
|
325
|
+
"position:fixed;left:0;right:0;bottom:0;z-index:2147483647;" +
|
|
326
|
+
"padding:20px 40px;box-sizing:border-box;text-align:center;pointer-events:none;" +
|
|
327
|
+
"background:linear-gradient(90deg,#1e40af 0%,#2563eb 50%,#1e40af 100%);" +
|
|
328
|
+
"color:#fff;font:600 23px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" +
|
|
329
|
+
"letter-spacing:.01em;text-shadow:0 1px 2px rgba(0,0,0,.35);" +
|
|
330
|
+
"box-shadow:0 -8px 30px rgba(37,99,235,.45);border-top:2px solid rgba(255,255,255,.35);" +
|
|
331
|
+
"opacity:0;transform:translateY(8px);transition:opacity .28s ease,transform .28s ease;";
|
|
332
|
+
(document.body || document.documentElement).appendChild(bar);
|
|
333
|
+
}
|
|
334
|
+
bar.textContent = text;
|
|
335
|
+
requestAnimationFrame(() => { bar.style.opacity = "1"; bar.style.transform = "translateY(0)"; });
|
|
336
|
+
}, { id: CAPTION_ID, text });
|
|
337
|
+
} catch { /* page navigating / no document yet — skip the caption for this beat */ }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function clearCaption(page) {
|
|
341
|
+
try {
|
|
342
|
+
await page.evaluate((id) => { const el = document.getElementById(id); if (el) el.remove(); }, CAPTION_ID);
|
|
343
|
+
} catch { /* nothing to clear */ }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- GIF export ---------------------------------------------------------------------------
|
|
347
|
+
// A recorded demo's .webm is great for downloading but useless for embedding where it counts
|
|
348
|
+
// (a GitHub README renders an animated GIF inline; it will not play a webm/mp4). So we offer a
|
|
349
|
+
// webm -> looping GIF conversion. ffmpeg is the converter — a system dep, NOT bundled, so this
|
|
350
|
+
// degrades gracefully: missing ffmpeg yields a clear install hint rather than a crash (mirrors
|
|
351
|
+
// the Chrome-for-PDF gate in export.js). The injectable `encoder` keeps it unit-testable
|
|
352
|
+
// without ffmpeg in CI.
|
|
353
|
+
|
|
354
|
+
/** Locate an ffmpeg binary (env/arg override wins, then PATH, then common install dirs). */
|
|
355
|
+
export function findFfmpeg(override) {
|
|
356
|
+
const candidates = [override, process.env.WI_FFMPEG, "ffmpeg",
|
|
357
|
+
"/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].filter(Boolean);
|
|
358
|
+
for (const c of candidates) {
|
|
359
|
+
try { if (spawnSync(c, ["-version"], { stdio: "ignore" }).status === 0) return c; }
|
|
360
|
+
catch { /* not this one */ }
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Default encoder: ffmpeg webm -> high-quality looping GIF. Two-pass palette (palettegen +
|
|
367
|
+
* paletteuse) so colours don't band; GitHub-friendly defaults (10fps, 720px wide) keep the
|
|
368
|
+
* file small enough to embed. Blocks while encoding (spawnSync) — same tradeoff as PDF render.
|
|
369
|
+
*/
|
|
370
|
+
export function ffmpegGifEncoder(webmPath, gifPath, { ffmpegPath, fps = 10, width = 720 } = {}) {
|
|
371
|
+
const ffmpeg = findFfmpeg(ffmpegPath);
|
|
372
|
+
if (!ffmpeg) throw new Error("ffmpeg not found — install it (e.g. `brew install ffmpeg` / `apt install ffmpeg`) or set WI_FFMPEG to enable GIF export");
|
|
373
|
+
const filter =
|
|
374
|
+
`fps=${fps},scale=${width}:-1:flags=lanczos,split[s0][s1];` +
|
|
375
|
+
`[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3`;
|
|
376
|
+
const r = spawnSync(ffmpeg, ["-y", "-i", webmPath, "-vf", filter, "-loop", "0", gifPath], { timeout: 180000 });
|
|
377
|
+
if (r.status !== 0 || !existsSync(gifPath)) {
|
|
378
|
+
throw new Error(`ffmpeg GIF encode failed (status ${r.status}): ${String(r.stderr || "").slice(0, 300)}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Export a recorded version's webm -> animated GIF, cached next to the recording. Re-encodes
|
|
384
|
+
* only when the GIF is missing or older than its source webm (a re-record supersedes it).
|
|
385
|
+
* @returns {{ path: string, bytes: number, cached: boolean }}
|
|
386
|
+
*/
|
|
387
|
+
export function exportGif(dir, version, { encoder = ffmpegGifEncoder, ffmpegPath, fps, width } = {}) {
|
|
388
|
+
const recDir = join(dir, RECORDINGS_DIR);
|
|
389
|
+
const webmPath = join(recDir, `_v${version}.webm`);
|
|
390
|
+
if (!existsSync(webmPath)) throw new Error(`no recording for v${version} — record the demo first`);
|
|
391
|
+
const gifPath = join(recDir, `_v${version}.gif`);
|
|
392
|
+
if (existsSync(gifPath) && statSync(gifPath).mtimeMs >= statSync(webmPath).mtimeMs) {
|
|
393
|
+
return { path: gifPath, bytes: statSync(gifPath).size, cached: true };
|
|
394
|
+
}
|
|
395
|
+
encoder(webmPath, gifPath, { ffmpegPath, fps, width });
|
|
396
|
+
return { path: gifPath, bytes: statSync(gifPath).size, cached: false };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Newest .webm written into `dir` since `sinceMs` — fallback when page.video() path is unavailable. */
|
|
400
|
+
function newestWebm(dir, sinceMs) {
|
|
401
|
+
let best = null, bestMtime = sinceMs - 1000;
|
|
402
|
+
for (const f of readdirSync(dir)) {
|
|
403
|
+
if (!f.endsWith(".webm")) continue;
|
|
404
|
+
const full = join(dir, f);
|
|
405
|
+
try {
|
|
406
|
+
const mt = statSync(full).mtimeMs;
|
|
407
|
+
if (mt >= bestMtime) { bestMtime = mt; best = full; }
|
|
408
|
+
} catch { /* skip */ }
|
|
409
|
+
}
|
|
410
|
+
return best;
|
|
411
|
+
}
|