@marver-design/marver 0.13.0 → 0.15.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/CHANGELOG.md +162 -0
- package/README.md +44 -20
- package/dist/{build-BxGrHFT2.mjs → build-DfuTQZlY.mjs} +46 -6
- package/dist/cli.mjs +21 -7
- package/dist/{daemon-BChkzDqQ.mjs → daemon-DalgvoA9.mjs} +1 -1
- package/dist/{dev-DLwt3Brb.mjs → dev-BxCmeU_H.mjs} +14 -4
- package/dist/{init-BpitOqRQ.mjs → init-QKNi9gvF.mjs} +66 -2
- package/dist/{manifest-CS6krOTe.mjs → manifest-BzxSMoDB.mjs} +24 -6
- package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
- package/dist/{plugin-DNc4Jpae.mjs → plugin-DJyjmQeh.mjs} +60 -10
- package/dist/poster-CbpzSzJu.mjs +143 -0
- package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
- package/dist/{shot-Cyv3GN79.mjs → shot-BWhoz6cU.mjs} +204 -57
- package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
- package/docs/live-jam.md +177 -0
- package/docs/publish.md +270 -0
- package/docs/sharing.md +333 -0
- package/docs/slides.md +140 -0
- package/package.json +3 -1
- package/src/client/const.ts +13 -0
- package/src/client/content/chart-engine.ts +33 -0
- package/src/client/content/chart.tsx +138 -0
- package/src/client/content/index.tsx +30 -6
- package/src/client/content/slide.tsx +238 -0
- package/src/client/content/video.tsx +223 -0
- package/src/client/frame-host/bridge.js +6 -1
- package/src/client/shell/App.tsx +59 -13
- package/src/client/shell/LockedApp.tsx +7 -2
- package/src/client/shell/Play.tsx +138 -24
- package/src/client/shell/Toolbar.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +5 -3
- package/src/client/shell/hash.ts +3 -1
- package/src/client/shell/icons.tsx +3 -0
- package/src/client/shell/play-order.ts +22 -0
- package/src/client/shell/store.ts +80 -9
- package/src/client/shell/styles.css +23 -27
- package/src/client/stage/main.tsx +54 -3
- package/src/shared/utm.ts +3 -2
- package/templates/AGENTS-embedded.md +20 -4
- package/templates/AGENTS-studio.md +20 -4
- package/templates/instructions/boards.md +47 -5
- package/templates/instructions/craft.md +17 -0
- package/templates/instructions/iterate.md +109 -14
- package/templates/instructions/jam.md +18 -2
- package/templates/instructions/publish.md +7 -0
- package/templates/instructions/reference/deck-layouts.md +230 -0
- package/templates/instructions/reference/deck-story.md +110 -0
- package/templates/instructions/shape.md +15 -1
- package/templates/instructions/slides.md +402 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { i as ROUTE } from "./cli.mjs";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { a as slideSize, i as ROUTE } from "./cli.mjs";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
import { spawn } from "node:child_process";
|
|
6
7
|
//#region src/server/shot.ts
|
|
@@ -35,15 +36,23 @@ function findChrome() {
|
|
|
35
36
|
}
|
|
36
37
|
const slug = (frameId) => frameId.replace(/\//g, "--");
|
|
37
38
|
const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : void 0;
|
|
40
|
+
function planShot(frame, viewports, override = {}) {
|
|
41
|
+
const cw = num(frame.contentWidth);
|
|
40
42
|
const vpObj = frame.viewport ? viewports[frame.viewport] : void 0;
|
|
43
|
+
const ow = num(override.w), oh = num(override.h);
|
|
44
|
+
const sl = slideSize(frame);
|
|
45
|
+
if (sl) return {
|
|
46
|
+
width: sl.width,
|
|
47
|
+
initialHeight: sl.height,
|
|
48
|
+
fullHeight: false
|
|
49
|
+
};
|
|
41
50
|
const fallback = viewports.mobile ?? {
|
|
42
51
|
width: 390,
|
|
43
52
|
height: 844
|
|
44
53
|
};
|
|
45
54
|
if (cw) {
|
|
46
|
-
const width = clamp(vpObj?.width ?? cw, 320, 1600);
|
|
55
|
+
const width = clamp(ow ?? vpObj?.width ?? cw, 320, 1600);
|
|
47
56
|
return {
|
|
48
57
|
width,
|
|
49
58
|
initialHeight: Math.round(width * .75),
|
|
@@ -51,6 +60,11 @@ function planShot(frame, viewports) {
|
|
|
51
60
|
};
|
|
52
61
|
}
|
|
53
62
|
const vp = vpObj ?? fallback;
|
|
63
|
+
if (ow || oh) return {
|
|
64
|
+
width: clamp(ow ?? vp.width, 120, 3840),
|
|
65
|
+
initialHeight: clamp(oh ?? vp.height, 80, 2160),
|
|
66
|
+
fullHeight: false
|
|
67
|
+
};
|
|
54
68
|
return {
|
|
55
69
|
width: vp.width,
|
|
56
70
|
initialHeight: vp.height,
|
|
@@ -61,11 +75,15 @@ function planShot(frame, viewports) {
|
|
|
61
75
|
* transports (the HTTP /api/shot endpoint and the file-drop inbox), so they validate and
|
|
62
76
|
* name output identically. `origin` is the dev server's own base URL. */
|
|
63
77
|
async function shootFrame(opts) {
|
|
64
|
-
const { root, viewports, frameId, theme, origin } = opts;
|
|
78
|
+
const { root, viewports, frameId, theme, origin, scale = 2, size } = opts;
|
|
65
79
|
if (!/^[a-z0-9-]+$/i.test(theme)) return {
|
|
66
80
|
ok: false,
|
|
67
81
|
error: "invalid theme"
|
|
68
82
|
};
|
|
83
|
+
if (!Number.isInteger(scale) || scale < 1 || scale > 4) return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: "invalid scale"
|
|
86
|
+
};
|
|
69
87
|
let manifest = {};
|
|
70
88
|
try {
|
|
71
89
|
manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
|
|
@@ -75,38 +93,99 @@ async function shootFrame(opts) {
|
|
|
75
93
|
ok: false,
|
|
76
94
|
error: `unknown frame "${frameId}" - ids are in design/manifest.json`
|
|
77
95
|
};
|
|
78
|
-
const plan = planShot(frame, viewports);
|
|
96
|
+
const plan = planShot(frame, viewports, size);
|
|
97
|
+
if (frame.kind !== "html") try {
|
|
98
|
+
const { scanAssetRefs } = await import("./build-DfuTQZlY.mjs");
|
|
99
|
+
const { ensurePoster } = await import("./poster-CbpzSzJu.mjs");
|
|
100
|
+
const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
|
|
101
|
+
for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
|
|
102
|
+
} catch {}
|
|
79
103
|
const shotsDir = join(root, "design", ".local", "shots");
|
|
80
104
|
mkdirSync(shotsDir, { recursive: true });
|
|
81
|
-
|
|
105
|
+
sweepTemps(shotsDir);
|
|
106
|
+
const explicit = opts.scale !== void 0;
|
|
107
|
+
const relFor = (used) => {
|
|
108
|
+
const tag = !explicit || scale === 2 && used === 2 ? "" : used === scale ? `@${scale}x` : `@${scale}x-as-${used}x`;
|
|
109
|
+
return `design/.local/shots/${slug(frameId)}--${theme}${tag}.png`;
|
|
110
|
+
};
|
|
111
|
+
const tmp = join(shotsDir, `.${slug(frameId)}--${theme}.${randomBytes(6).toString("hex")}.tmp.png`);
|
|
82
112
|
const result = await capture({
|
|
83
113
|
url: frame.kind === "html" ? `${origin}/${frame.file}?theme=${encodeURIComponent(theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(frameId)}&theme=${encodeURIComponent(theme)}`,
|
|
84
114
|
width: plan.width,
|
|
85
115
|
height: plan.initialHeight,
|
|
86
|
-
out:
|
|
87
|
-
fullHeight: plan.fullHeight
|
|
116
|
+
out: tmp,
|
|
117
|
+
fullHeight: plan.fullHeight,
|
|
118
|
+
scale
|
|
88
119
|
});
|
|
89
|
-
|
|
120
|
+
if (!result.ok) {
|
|
121
|
+
rmSync(tmp, { force: true });
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
const rel = relFor(result.scale);
|
|
125
|
+
try {
|
|
126
|
+
try {
|
|
127
|
+
renameSync(tmp, join(root, rel));
|
|
128
|
+
} catch (err) {
|
|
129
|
+
const code = err.code;
|
|
130
|
+
if (code !== "EEXIST" && code !== "EPERM") throw err;
|
|
131
|
+
copyFileSync(tmp, join(root, rel));
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: `could not write the shot - ${err.message}`
|
|
137
|
+
};
|
|
138
|
+
} finally {
|
|
139
|
+
rmSync(tmp, { force: true });
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
90
142
|
ok: true,
|
|
91
143
|
path: rel,
|
|
92
144
|
width: result.width,
|
|
93
145
|
height: result.height,
|
|
94
146
|
scale: result.scale,
|
|
95
|
-
...result.truncated ? {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
147
|
+
...result.truncated ? { truncated: true } : {},
|
|
148
|
+
...result.note ? { note: result.note } : {}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** A crashed process can leave a `.tmp.png` behind; sweep them (older than a minute, so an in-
|
|
152
|
+
* flight capture in this process is never touched) at the next shot. */
|
|
153
|
+
const sweptAt = /* @__PURE__ */ new Map();
|
|
154
|
+
function sweepTemps(dir) {
|
|
155
|
+
if (Date.now() - (sweptAt.get(dir) ?? 0) < 6e4) return;
|
|
156
|
+
sweptAt.set(dir, Date.now());
|
|
157
|
+
try {
|
|
158
|
+
for (const f of readdirSync(dir)) {
|
|
159
|
+
if (!f.endsWith(".tmp.png")) continue;
|
|
160
|
+
const p = join(dir, f);
|
|
161
|
+
try {
|
|
162
|
+
if (Date.now() - statSync(p).mtimeMs > 6e4) rmSync(p, { force: true });
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
100
166
|
}
|
|
101
|
-
/** One capture at a time: shots are seconds apart at most, and a Chrome per
|
|
102
|
-
* request would stampede the machine mid-jam.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
167
|
+
/** One capture at a time PER LANE: shots are seconds apart at most, and a Chrome per
|
|
168
|
+
* concurrent request would stampede the machine mid-jam. Posters have their own lane - a
|
|
169
|
+
* frame being shot may ask for its poster mid-render, and on the shot's lane that request
|
|
170
|
+
* would wait for the very shot that is waiting for it. */
|
|
171
|
+
const chains = /* @__PURE__ */ new Map();
|
|
172
|
+
function capture(req, lane = "shot") {
|
|
173
|
+
const run = (chains.get(lane) ?? Promise.resolve()).then(() => captureNow(req), () => captureNow(req));
|
|
174
|
+
chains.set(lane, run);
|
|
107
175
|
return run;
|
|
108
176
|
}
|
|
109
|
-
|
|
177
|
+
/** The capture budget. Chrome's surface tops out near 16384 device px per SIDE, and a bitmap is
|
|
178
|
+
* also bounded by its AREA: 64M device px (~256 MiB RGBA) is comfortably rendered, encoded and
|
|
179
|
+
* pasted, where a 3840×2160@4 (133M px) can stall the renderer or the paste target. Both limits
|
|
180
|
+
* are exported so the tests hold the same numbers. */
|
|
181
|
+
const SURFACE = 16384;
|
|
182
|
+
const AREA = 64e6;
|
|
183
|
+
const fits = (w, h, dsf) => w * dsf <= 16384 && h * dsf <= 16384 && w * h * dsf * dsf <= 64e6;
|
|
184
|
+
/** The tallest CSS height a full-height capture of `width` can hold at `dsf`. */
|
|
185
|
+
const capFor = (width, dsf) => Math.max(1, Math.min(Math.floor(SURFACE / dsf), Math.floor(AREA / (width * dsf * dsf))));
|
|
186
|
+
async function captureNow({ url, width, height, out, fullHeight = false, timeoutMs = 3e4, scale: wantScale = 2, clip }) {
|
|
187
|
+
let scale = Math.min(4, Math.max(1, Math.round(wantScale)));
|
|
188
|
+
while (scale > 1 && !fits(width, height, scale)) scale--;
|
|
110
189
|
const bin = findChrome();
|
|
111
190
|
if (!bin) return {
|
|
112
191
|
ok: false,
|
|
@@ -199,11 +278,14 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
199
278
|
}
|
|
200
279
|
});
|
|
201
280
|
const sendD = (method, params, ms = 1500) => Promise.race([send(method, params, sessionId), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error(`${method} timed out`)), ms))]);
|
|
281
|
+
const hardBy = Date.now() + timeoutMs + 15e3;
|
|
202
282
|
watchdog = setTimeout(() => {
|
|
203
283
|
try {
|
|
204
284
|
chrome.kill("SIGKILL");
|
|
205
285
|
} catch {}
|
|
206
286
|
}, timeoutMs + 15e3);
|
|
287
|
+
const RESERVE = 21e3;
|
|
288
|
+
const discretionary = () => hardBy - RESERVE - Date.now();
|
|
207
289
|
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
|
|
208
290
|
const { sessionId } = await send("Target.attachToTarget", {
|
|
209
291
|
targetId,
|
|
@@ -212,7 +294,7 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
212
294
|
await send("Emulation.setDeviceMetricsOverride", {
|
|
213
295
|
width,
|
|
214
296
|
height,
|
|
215
|
-
deviceScaleFactor:
|
|
297
|
+
deviceScaleFactor: scale,
|
|
216
298
|
mobile: false
|
|
217
299
|
}, sessionId);
|
|
218
300
|
await send("Page.enable", {}, sessionId);
|
|
@@ -238,12 +320,55 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
238
320
|
ok: false,
|
|
239
321
|
error: `the frame never rendered${lastException ? ` - the page threw: ${lastException}` : " (no exception surfaced - is the dev server reachable from this machine?)"}`
|
|
240
322
|
};
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
323
|
+
const SETTLED = `(() => {
|
|
324
|
+
if (typeof window.__mvLodBusy === 'function' && window.__mvLodBusy() > 0) return false
|
|
325
|
+
if (typeof window.__mvPosterBusy === 'function' && window.__mvPosterBusy() > 0) return false
|
|
326
|
+
const H = window.innerHeight, W = window.innerWidth
|
|
327
|
+
for (const im of document.images) {
|
|
328
|
+
if (im.complete) continue
|
|
329
|
+
const r = im.getBoundingClientRect()
|
|
330
|
+
if (r.bottom < 0 || r.top > H || r.right < 0 || r.left > W) continue
|
|
331
|
+
return false
|
|
332
|
+
}
|
|
333
|
+
for (const c of document.querySelectorAll('.mv-chart')) if (!c.querySelector('svg, canvas')) return false
|
|
334
|
+
for (const d of document.querySelectorAll('.mv-diagram')) if (!d.querySelector('.mv-diagram-svg svg, .mv-diagram-err')) return false
|
|
335
|
+
return true
|
|
336
|
+
})()`;
|
|
337
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
338
|
+
const settle = async (budgetMs) => {
|
|
339
|
+
const total = Math.min(budgetMs, discretionary());
|
|
340
|
+
if (total <= 0) return false;
|
|
341
|
+
const by = Date.now() + total;
|
|
342
|
+
const left = () => Math.max(1, by - Date.now());
|
|
343
|
+
await sendD("Runtime.evaluate", {
|
|
344
|
+
expression: `window.postMessage({type:'sh:camera',moving:false,scale:1}, location.origin)`,
|
|
345
|
+
returnByValue: true
|
|
346
|
+
}, left()).catch(() => null);
|
|
347
|
+
await sendD("Runtime.evaluate", {
|
|
348
|
+
expression: "document.fonts.ready.then(() => true)",
|
|
349
|
+
awaitPromise: true,
|
|
350
|
+
returnByValue: true
|
|
351
|
+
}, left()).catch(() => null);
|
|
352
|
+
let ok = false;
|
|
353
|
+
while (Date.now() < by) {
|
|
354
|
+
if ((await sendD("Runtime.evaluate", {
|
|
355
|
+
expression: SETTLED,
|
|
356
|
+
returnByValue: true
|
|
357
|
+
}, left()).catch(() => null))?.result?.value === true) {
|
|
358
|
+
ok = true;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
await wait(Math.min(100, left()));
|
|
362
|
+
}
|
|
363
|
+
if (Date.now() < by) await sendD("Runtime.evaluate", {
|
|
364
|
+
expression: "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))",
|
|
365
|
+
awaitPromise: true,
|
|
366
|
+
returnByValue: true
|
|
367
|
+
}, Math.min(1e3, left())).catch(() => null);
|
|
368
|
+
if (Date.now() < by) await wait(Math.min(ok ? 150 : 250, left()));
|
|
369
|
+
return ok;
|
|
370
|
+
};
|
|
371
|
+
await settle(3e3);
|
|
247
372
|
const errEval = await send("Runtime.evaluate", {
|
|
248
373
|
expression: "window.__mvFrameError || \"\"",
|
|
249
374
|
returnByValue: true
|
|
@@ -253,9 +378,8 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
253
378
|
ok: false,
|
|
254
379
|
error: `the frame rendered an error - ${frameError}`
|
|
255
380
|
};
|
|
256
|
-
let capW = width, capH = height,
|
|
381
|
+
let capW = width, capH = height, truncated = false, note = "";
|
|
257
382
|
if (fullHeight) {
|
|
258
|
-
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
259
383
|
const measureH = async () => {
|
|
260
384
|
const v = (await sendD("Runtime.evaluate", {
|
|
261
385
|
expression: `Math.ceil((document.querySelector('.mv-doc')||document.getElementById('root')||document.body).getBoundingClientRect().height)`,
|
|
@@ -300,46 +424,71 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
300
424
|
h = Math.max(m, capped);
|
|
301
425
|
}
|
|
302
426
|
};
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
await growFit(
|
|
306
|
-
if (measured >
|
|
427
|
+
const settleDeadline = Date.now() + Math.max(0, Math.min(6e3, discretionary()));
|
|
428
|
+
const asked = scale;
|
|
429
|
+
await growFit(scale, capFor(width, scale), settleDeadline);
|
|
430
|
+
if (measured > capFor(width, scale) && scale > 2) {
|
|
431
|
+
scale = 2;
|
|
432
|
+
await growFit(2, capFor(width, 2), settleDeadline);
|
|
433
|
+
}
|
|
434
|
+
if (measured > capFor(width, scale) && scale > 1) {
|
|
307
435
|
scale = 1;
|
|
308
|
-
await growFit(1,
|
|
436
|
+
await growFit(1, capFor(width, 1), settleDeadline);
|
|
309
437
|
}
|
|
310
|
-
const cap = scale
|
|
438
|
+
const cap = capFor(width, scale);
|
|
311
439
|
capW = width;
|
|
312
440
|
if (measured > cap) {
|
|
313
441
|
capH = cap;
|
|
314
442
|
truncated = true;
|
|
315
443
|
note = `frame is ${measured}px tall; captured the top ${cap}px - split it or reduce its height`;
|
|
316
444
|
} else capH = clamp(measured || height, 80, cap);
|
|
445
|
+
if (scale < asked && !truncated) note = `frame is ${measured}px tall - too tall for ${asked}x, captured at ${scale}x`;
|
|
317
446
|
await sendD("Emulation.setDeviceMetricsOverride", {
|
|
318
447
|
width: capW,
|
|
319
448
|
height: capH,
|
|
320
449
|
deviceScaleFactor: scale,
|
|
321
450
|
mobile: false
|
|
322
451
|
}, 5e3);
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
452
|
+
await wait(Math.min(300, Math.max(0, discretionary())));
|
|
453
|
+
await settle(Math.max(1200, settleDeadline - Date.now()));
|
|
454
|
+
const m2 = await measureH();
|
|
455
|
+
if (m2 > capH) {
|
|
456
|
+
if (m2 <= cap) capH = m2;
|
|
457
|
+
else {
|
|
458
|
+
capH = cap;
|
|
459
|
+
truncated = true;
|
|
460
|
+
note = `frame is ${m2}px tall; captured the top ${cap}px - split it or reduce its height`;
|
|
461
|
+
}
|
|
462
|
+
await sendD("Emulation.setDeviceMetricsOverride", {
|
|
463
|
+
width: capW,
|
|
464
|
+
height: capH,
|
|
465
|
+
deviceScaleFactor: scale,
|
|
466
|
+
mobile: false
|
|
467
|
+
}, 5e3);
|
|
468
|
+
await settle(600);
|
|
335
469
|
}
|
|
336
470
|
}
|
|
471
|
+
let capX = 0, capY = 0;
|
|
472
|
+
if (clip) {
|
|
473
|
+
const box = (await sendD("Runtime.evaluate", {
|
|
474
|
+
expression: `(() => { const el = document.querySelector(${JSON.stringify(clip)}); if (!el) return null; const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height } })()`,
|
|
475
|
+
returnByValue: true
|
|
476
|
+
}, 2e3).catch(() => null))?.result?.value;
|
|
477
|
+
if (!box || box.w < 1 || box.h < 1) return {
|
|
478
|
+
ok: false,
|
|
479
|
+
error: `nothing to capture at "${clip}"`
|
|
480
|
+
};
|
|
481
|
+
capX = Math.max(0, Math.floor(box.x));
|
|
482
|
+
capY = Math.max(0, Math.floor(box.y));
|
|
483
|
+
capW = Math.max(1, Math.round(box.w));
|
|
484
|
+
capH = Math.max(1, Math.round(box.h));
|
|
485
|
+
}
|
|
337
486
|
const shot = await sendD("Page.captureScreenshot", {
|
|
338
487
|
format: "png",
|
|
339
488
|
captureBeyondViewport: true,
|
|
340
489
|
clip: {
|
|
341
|
-
x:
|
|
342
|
-
y:
|
|
490
|
+
x: capX,
|
|
491
|
+
y: capY,
|
|
343
492
|
width: capW,
|
|
344
493
|
height: capH,
|
|
345
494
|
scale: 1
|
|
@@ -351,10 +500,8 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
351
500
|
width: capW,
|
|
352
501
|
height: capH,
|
|
353
502
|
scale,
|
|
354
|
-
...truncated ? {
|
|
355
|
-
|
|
356
|
-
note
|
|
357
|
-
} : {}
|
|
503
|
+
...truncated ? { truncated: true } : {},
|
|
504
|
+
...note ? { note } : {}
|
|
358
505
|
};
|
|
359
506
|
} catch (err) {
|
|
360
507
|
return {
|
|
@@ -378,4 +525,4 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
378
525
|
}
|
|
379
526
|
}
|
|
380
527
|
//#endregion
|
|
381
|
-
export { shootFrame };
|
|
528
|
+
export { capture, findChrome, shootFrame };
|
|
@@ -8,12 +8,13 @@ import { readDevInfo } from "./work-CLrmY-vQ.mjs";
|
|
|
8
8
|
* Thin wrapper over the dev server's /api/shot (shot.ts has the capture story).
|
|
9
9
|
*/
|
|
10
10
|
async function shotCommand(root, frame, opts) {
|
|
11
|
-
if (!frame) throw new Error(`name the frame: ${NAME} shot <scene/frame> [--theme <name>]`);
|
|
11
|
+
if (!frame) throw new Error(`name the frame: ${NAME} shot <scene/frame> [--theme <name>] [--scale 1-4]`);
|
|
12
12
|
const info = readDevInfo(root);
|
|
13
13
|
if (!info) throw new Error(`\`${NAME} dev\` is not running in this repo (design/.local/dev.json not found) - start it first.`);
|
|
14
14
|
const qs = new URLSearchParams({
|
|
15
15
|
frame,
|
|
16
|
-
...opts.theme ? { theme: opts.theme } : {}
|
|
16
|
+
...opts.theme ? { theme: opts.theme } : {},
|
|
17
|
+
...opts.scale != null ? { scale: String(opts.scale) } : {}
|
|
17
18
|
});
|
|
18
19
|
let res;
|
|
19
20
|
try {
|
package/docs/live-jam.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Live Jam
|
|
2
|
+
|
|
3
|
+
Tag `@marver` in a canvas comment while `npx marver dev` is running. The dev server spawns
|
|
4
|
+
your own coding-agent CLI headless with that one job, the frame lights up with a working
|
|
5
|
+
glow, the agent edits the real frame source, and its reply lands back in the same thread.
|
|
6
|
+
No round trip to the terminal. Marver ships no AI: the agent that acts is the one you
|
|
7
|
+
already run and pay for.
|
|
8
|
+
|
|
9
|
+
## It arms itself
|
|
10
|
+
|
|
11
|
+
Live Jam is on by default (since 0.9.0). Marver speaks seven agent CLIs - `claude`,
|
|
12
|
+
`codex`, `cursor`, `droid` (Factory), `opencode`, `grok`, and `pi` - which also covers the
|
|
13
|
+
apps built on them: Factory drives `droid`, Cursor drives `cursor-agent`, Conductor drives
|
|
14
|
+
`claude`. Marver looks for one in this order:
|
|
15
|
+
|
|
16
|
+
1. **The tool running the process wins.** Most CLIs export env markers into what they spawn
|
|
17
|
+
(`CLAUDECODE` for Claude Code, `CODEX_SANDBOX` for Codex, `CURSOR_AGENT` for Cursor,
|
|
18
|
+
`OPENCODE` for opencode, `PI_CODING_AGENT` for pi), and `marver init` is usually run by
|
|
19
|
+
the agent itself. That is evidence, not a guess. droid and grok set no marker in the
|
|
20
|
+
shells they spawn, so this step cannot see them - name them in config or let PATH decide.
|
|
21
|
+
2. **Otherwise, whatever is on PATH**, in the order above - `claude` first.
|
|
22
|
+
|
|
23
|
+
That second step is a guess, so the answer is made visible rather than clever: `init` prints
|
|
24
|
+
the agent it chose and writes it into `design/config.ts` in plain sight, and `marver dev`
|
|
25
|
+
names it at boot (`jam: on (claude)`). One word to correct, once per repo.
|
|
26
|
+
|
|
27
|
+
The candidate has to be executable on `PATH` under its bare name, because the daemon spawns
|
|
28
|
+
it without a shell. A shell alias or function is invisible to it. Cursor is the one agent
|
|
29
|
+
whose binary differs from its config name: marver spawns `cursor-agent`, never the bare
|
|
30
|
+
`agent` - both Cursor and grok install an `agent` name, so the short one is a coin flip.
|
|
31
|
+
|
|
32
|
+
With no agent CLI installed, jam stays off and both `init` and `marver dev` say so instead
|
|
33
|
+
of going quiet. Workspaces created before 0.9.0 need no re-init; they resolve the same way
|
|
34
|
+
at every dev boot.
|
|
35
|
+
|
|
36
|
+
## The config block
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// design/config.ts
|
|
40
|
+
jam: { agent: "claude", concurrency: 6 },
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Key | Default | What it does |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| `agent` | detected | The CLI the daemon spawns: `"claude"`, `"codex"`, `"cursor"`, `"droid"`, `"opencode"`, `"grok"`, or `"pi"` |
|
|
46
|
+
| `concurrency` | `6` | Frames worked on at once (1-16). The same frame never gets two agents |
|
|
47
|
+
| `subagents` | `true` | Inside one job, fan out one subagent per frame |
|
|
48
|
+
|
|
49
|
+
Shorthands: `jam: "codex"` names the agent and takes the rest of the defaults, `jam: true`
|
|
50
|
+
is the default block, and **`jam: false` is the off switch**.
|
|
51
|
+
|
|
52
|
+
A named agent is never quietly swapped for another. If `jam.agent` names something marver
|
|
53
|
+
cannot spawn, or names a CLI that is not on PATH, Live Jam turns off with a printed reason
|
|
54
|
+
rather than answering your comments with a tool you did not choose. A `design/config.ts`
|
|
55
|
+
that fails to parse also leaves jam off, since it may have been the file that said
|
|
56
|
+
`jam: false`.
|
|
57
|
+
|
|
58
|
+
## What the agent may do
|
|
59
|
+
|
|
60
|
+
Be clear-eyed about what this is. The real protection is not a sandbox - it is that the
|
|
61
|
+
agent doing the work is **your own**, running on **your machine**, and **every change it
|
|
62
|
+
makes is a diff you review** before anything is resolved. On top of that, marver removes the
|
|
63
|
+
one tool that would turn a prompt-injected comment into silent damage: an unrestricted
|
|
64
|
+
**shell**. Each CLI is spawned so the model can read and edit files but cannot open a shell
|
|
65
|
+
(or, for Codex and Cursor, only a shell the OS sandbox contains and cuts off from the
|
|
66
|
+
network):
|
|
67
|
+
|
|
68
|
+
| | How it is spawned |
|
|
69
|
+
|---|---|
|
|
70
|
+
| **Claude Code** | `claude -p --permission-mode acceptEdits` with an allowlist of Read, Edit, Write, Glob, Grep, WebSearch, WebFetch - and `--disallowedTools Bash`, so there is no shell |
|
|
71
|
+
| **Codex** | `codex exec -s workspace-write`, its own OS sandbox, which bounds what commands touch and blocks network egress, but still lets the model run shell commands |
|
|
72
|
+
| **Cursor** | `cursor-agent -p --sandbox enabled --trust` - cursor's print mode carries a shell, so like Codex it runs inside the OS sandbox (verified: network egress is blocked); `--trust` only answers the workspace-trust prompt for the repo you already run `marver dev` in, and `--force` is never passed |
|
|
73
|
+
| **droid** | `droid exec --auto low` for file edits, with `--disabled-tools` removing the shell (`Execute`), the delegation tools (`Task`, missions), and the Slack/connector tools |
|
|
74
|
+
| **opencode** | `opencode run --pure` (no external plugins) with a per-run DEFAULT-DENY `OPENCODE_PERMISSION` grant - read/edit/search/web/task allowed by name, everything else (bash included) denied - never its all-approving `--auto` flag |
|
|
75
|
+
| **grok** | `grok -p --tools read_file,search_replace,list_dir,grep,todo_write` - an ALLOWLIST of read/edit tools only, so the shell, web, and subagents are simply absent (a deny-list is a footgun: it can miss a tool's real name); `--permission-mode acceptEdits` auto-applies the edits |
|
|
76
|
+
| **pi** | `pi -p --tools read,edit,write,grep,find,ls --no-extensions --no-skills` - pi has no runtime permission system, so the tool allowlist IS the jail, and bash is not on it |
|
|
77
|
+
|
|
78
|
+
The honest limits: file tools that take a path (Read/Edit/Write, and their equivalents) are
|
|
79
|
+
not themselves jailed to `design/` - on the CLIs without an OS sandbox they can, if a
|
|
80
|
+
comment talks the model into it, touch a file elsewhere in the repo or the machine. And Web
|
|
81
|
+
access, where a CLI keeps it, can carry data outward. This is the same boundary Claude Code
|
|
82
|
+
has always run under, and it is why the two rules above still do the real work: it is your
|
|
83
|
+
agent, and you review the diff. Do not point Live Jam at a repo, or run it on a machine, you
|
|
84
|
+
would not hand that same agent directly.
|
|
85
|
+
|
|
86
|
+
Web access stays on where the CLI offers it (Claude Code, Codex, opencode): reference sites
|
|
87
|
+
and real brand SVGs are how a frame stops looking like a placeholder. The agent never
|
|
88
|
+
resolves a thread; you do that after reviewing.
|
|
89
|
+
|
|
90
|
+
The missing sense that no-shell used to cost - "does my frame actually RENDER?" - is a
|
|
91
|
+
server capability instead, rendered in the machine's own headless Chrome (no bundled
|
|
92
|
+
browser, CDP over Node's built-in WebSocket) and written as a PNG under
|
|
93
|
+
`design/.local/shots/`. Two transports reach it, because the no-shell jail rules out the
|
|
94
|
+
obvious one:
|
|
95
|
+
|
|
96
|
+
- **The file-drop inbox** (works for every agent, including Claude Code, which has no shell
|
|
97
|
+
and whose WebFetch refuses localhost). The agent writes
|
|
98
|
+
`design/.local/shots/<slug>.request.json` with `{"frame":"<id>","theme":"<t>"}`; the dev
|
|
99
|
+
server renders and writes `<slug>.result.json` with the PNG path or an error, which the
|
|
100
|
+
agent Reads.
|
|
101
|
+
- **`npx marver shot <frame> [--scale 1-4]`** / `GET /api/shot?frame=<id>&theme=<t>&scale=<n>`
|
|
102
|
+
for humans and shell-ful agents - the same renderer, one line. Default 2x; `--scale 4` for a
|
|
103
|
+
print-quality still (a slide comes back 5120×2880). A frame too tall for the asked scale steps
|
|
104
|
+
down and says so in `note`; the file name carries the scale actually used (`…@4x.png`).
|
|
105
|
+
The canvas's **copy as image** (`i` / `⇧i`, the images-square toolbar button) is this same
|
|
106
|
+
renderer with `format=png`, so what a designer pastes and what an agent shoots is one picture.
|
|
107
|
+
|
|
108
|
+
The generated jam instructions tell every agent to shoot and LOOK before replying "done".
|
|
109
|
+
A frame that never mounts, or a dev server that isn't reachable, comes back as an honest
|
|
110
|
+
`{"ok":false,"error":...}` carrying the real cause - never a blank that reads as success.
|
|
111
|
+
|
|
112
|
+
One prerequisite marver cannot arrange: **the CLI has to be logged in** (`droid` and
|
|
113
|
+
`cursor-agent login` and `grok login` each have their own flow; opencode and pi can also
|
|
114
|
+
read provider API keys from the environment). An unauthenticated CLI fails the job; the
|
|
115
|
+
daemon retries once, then replies that it could not finish - the dev log and
|
|
116
|
+
`design/.local/jam-logs/` say why.
|
|
117
|
+
|
|
118
|
+
## The trust boundary
|
|
119
|
+
|
|
120
|
+
Only comments written on the owner's machine can start work. A mention becomes eligible
|
|
121
|
+
solely by way of the local dev server's owner-gated endpoint (a CSRF double-submit cookie
|
|
122
|
+
plus an Origin allowlist), which appends it to `design/.local/jam-ledger`. The daemon runs
|
|
123
|
+
a job only for an event id that is already in that ledger, so a drive-by comment on a
|
|
124
|
+
published canvas cannot trigger one, and neither can a collaborator comment that arrived
|
|
125
|
+
through `marver comments sync`.
|
|
126
|
+
|
|
127
|
+
The ledger and the job journal both carry a device stamp, because gitignore is a convention
|
|
128
|
+
and not provenance: a repo can force-add its own `.local/`. Jam state that arrived with a
|
|
129
|
+
clone is read as absent. The stamp is derived from the machine rather than stored, so
|
|
130
|
+
marver still writes nothing outside `design/`.
|
|
131
|
+
|
|
132
|
+
The larger caution is unchanged and worth stating plainly: `marver dev` imports and executes
|
|
133
|
+
`design/config.ts`, so running a dev server in a repo you do not trust is already running
|
|
134
|
+
that repo's code. Live Jam adds no new hole to that; it does not make it safe.
|
|
135
|
+
|
|
136
|
+
## Provenance
|
|
137
|
+
|
|
138
|
+
Every jam reply is stamped with who acted: your dev user, the harness that ran, and the
|
|
139
|
+
model when the agent names one. Claude Code, Cursor, droid, grok, and pi report their model
|
|
140
|
+
in the stream; `codex exec` and `opencode run` report none, so their replies carry the
|
|
141
|
+
harness without a model rather than a guessed one.
|
|
142
|
+
|
|
143
|
+
## Parallelism
|
|
144
|
+
|
|
145
|
+
Two knobs stack, and they are not the same thing. `jam.concurrency` is how many jobs the
|
|
146
|
+
daemon runs at once - different frames, different comments. `jam.subagents` is fan-out
|
|
147
|
+
*inside* one job, one subagent per frame, which is what makes a five-frame ask land
|
|
148
|
+
together instead of in series. Claude Code, Codex, and opencode fan out (opencode's
|
|
149
|
+
subagents verifiably inherit the jail); pi has no subagent tool, and droid and grok have
|
|
150
|
+
theirs removed in the spawn itself (`--disabled-tools Task`, `--no-subagents`) until a
|
|
151
|
+
child is proven to inherit the parent's confinement. Their jobs simply run the frames in
|
|
152
|
+
sequence - the prompt only ever says the agent MAY fan out.
|
|
153
|
+
|
|
154
|
+
Two honest caveats in the same spirit as the config-execution one above: cursor's own
|
|
155
|
+
permission rules (`~/.cursor/cli-config.json`, a repo's `.cursor/cli.json`) and a repo's
|
|
156
|
+
own `opencode.json` agent block can widen what those CLIs allow - that is your
|
|
157
|
+
configuration speaking, and marver does not override it.
|
|
158
|
+
|
|
159
|
+
## When a mention does nothing
|
|
160
|
+
|
|
161
|
+
- **It has to be your machine, your repo, with `marver dev` running.** That is the trust
|
|
162
|
+
boundary above, working as intended.
|
|
163
|
+
- **The first boot after upgrading to 0.9.0 rebaselines the job journal**, because an
|
|
164
|
+
existing journal predates the device stamp. Any `@marver` left unprocessed while the
|
|
165
|
+
server was down is marked seen instead of run. Re-comment to pick it up.
|
|
166
|
+
- **Check the boot line.** `marver dev` prints `jam: on (<agent>)` when it is armed, and
|
|
167
|
+
prints the reason when it is not.
|
|
168
|
+
- **Read the raw run log.** Every job's full agent output lands in
|
|
169
|
+
`design/.local/jam-logs/` (last 10 kept) - an auth failure or permission refusal
|
|
170
|
+
explains itself there. The generated `design/instructions/jam.md` carries the full
|
|
171
|
+
troubleshooting drill, written for your agent to run: it checks the boot line, the log,
|
|
172
|
+
and the CLI's own headless auth, fixes what belongs to the workspace, and files what
|
|
173
|
+
belongs to marver at [github.com/TNEP4/marver/issues](https://github.com/TNEP4/marver/issues) -
|
|
174
|
+
with the patch, when it found one while debugging.
|
|
175
|
+
|
|
176
|
+
`npx marver comments list --open --json` reads the same threads without the live loop, for
|
|
177
|
+
catching up or for a one-off answer.
|