@marver-design/marver 0.14.0 → 0.16.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 +164 -0
- package/README.md +7 -4
- package/dist/boards-6hKVW42a.mjs +57 -0
- package/dist/boards-BdG1TJwU.mjs +267 -0
- package/dist/{build-ByYafIhj.mjs → build-Ct0iMoSi.mjs} +77 -30
- package/dist/cli.mjs +15 -6
- package/dist/{daemon-Bfucyf1o.mjs → daemon-Cb4JjpgL.mjs} +1 -1
- package/dist/{dev-yZMyQeUj.mjs → dev-CdcIuhZJ.mjs} +14 -4
- package/dist/{init-Dvaso7YO.mjs → init-BQIpIpIv.mjs} +6 -3
- package/dist/{manifest-DvOmglFp.mjs → manifest-CpbsqQ_v.mjs} +94 -17
- package/dist/{plugin-BsmG5i2X.mjs → plugin-BXyezwfN.mjs} +201 -74
- package/dist/poster-DOY7pax8.mjs +143 -0
- package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
- package/dist/{shot-kbR_xzJH.mjs → shot-CwmHO5T4.mjs} +197 -56
- package/docs/live-jam.md +6 -2
- package/docs/publish.md +4 -1
- package/docs/slides.md +9 -3
- package/package.json +1 -1
- package/src/client/content/chart.tsx +62 -24
- package/src/client/content/index.tsx +5 -2
- package/src/client/content/video.tsx +132 -35
- package/src/client/frame-host/bridge.js +6 -1
- package/src/client/shell/App.tsx +33 -247
- package/src/client/shell/BoardList.tsx +408 -0
- package/src/client/shell/ContextMenu.tsx +59 -0
- package/src/client/shell/icons.tsx +7 -0
- package/src/client/shell/store.ts +156 -48
- package/src/client/shell/styles.css +42 -4
- package/src/shared/board-tree.ts +285 -0
- package/templates/AGENTS-embedded.md +35 -7
- package/templates/AGENTS-studio.md +35 -7
- package/templates/instructions/boards.md +120 -7
- package/templates/instructions/craft.md +21 -0
- package/templates/instructions/discover.md +7 -2
- package/templates/instructions/iterate.md +114 -18
- package/templates/instructions/jam.md +18 -2
- package/templates/instructions/review.md +4 -0
- package/templates/instructions/shape.md +16 -2
- package/templates/instructions/slides.md +5 -1
- package/templates/instructions/welcome.md +4 -1
- package/templates/instructions/wireframe.md +3 -0
- package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { a as slideSize, i as ROUTE } from "./cli.mjs";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
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,9 +36,11 @@ 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);
|
|
41
44
|
const sl = slideSize(frame);
|
|
42
45
|
if (sl) return {
|
|
43
46
|
width: sl.width,
|
|
@@ -49,7 +52,7 @@ function planShot(frame, viewports) {
|
|
|
49
52
|
height: 844
|
|
50
53
|
};
|
|
51
54
|
if (cw) {
|
|
52
|
-
const width = clamp(vpObj?.width ?? cw, 320, 1600);
|
|
55
|
+
const width = clamp(ow ?? vpObj?.width ?? cw, 320, 1600);
|
|
53
56
|
return {
|
|
54
57
|
width,
|
|
55
58
|
initialHeight: Math.round(width * .75),
|
|
@@ -57,6 +60,11 @@ function planShot(frame, viewports) {
|
|
|
57
60
|
};
|
|
58
61
|
}
|
|
59
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
|
+
};
|
|
60
68
|
return {
|
|
61
69
|
width: vp.width,
|
|
62
70
|
initialHeight: vp.height,
|
|
@@ -67,11 +75,15 @@ function planShot(frame, viewports) {
|
|
|
67
75
|
* transports (the HTTP /api/shot endpoint and the file-drop inbox), so they validate and
|
|
68
76
|
* name output identically. `origin` is the dev server's own base URL. */
|
|
69
77
|
async function shootFrame(opts) {
|
|
70
|
-
const { root, viewports, frameId, theme, origin } = opts;
|
|
78
|
+
const { root, viewports, frameId, theme, origin, scale = 2, size } = opts;
|
|
71
79
|
if (!/^[a-z0-9-]+$/i.test(theme)) return {
|
|
72
80
|
ok: false,
|
|
73
81
|
error: "invalid theme"
|
|
74
82
|
};
|
|
83
|
+
if (!Number.isInteger(scale) || scale < 1 || scale > 4) return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: "invalid scale"
|
|
86
|
+
};
|
|
75
87
|
let manifest = {};
|
|
76
88
|
try {
|
|
77
89
|
manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
|
|
@@ -81,38 +93,99 @@ async function shootFrame(opts) {
|
|
|
81
93
|
ok: false,
|
|
82
94
|
error: `unknown frame "${frameId}" - ids are in design/manifest.json`
|
|
83
95
|
};
|
|
84
|
-
const plan = planShot(frame, viewports);
|
|
96
|
+
const plan = planShot(frame, viewports, size);
|
|
97
|
+
if (frame.kind !== "html") try {
|
|
98
|
+
const { scanAssetRefs } = await import("./build-Ct0iMoSi.mjs");
|
|
99
|
+
const { ensurePoster } = await import("./poster-DOY7pax8.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 {}
|
|
85
103
|
const shotsDir = join(root, "design", ".local", "shots");
|
|
86
104
|
mkdirSync(shotsDir, { recursive: true });
|
|
87
|
-
|
|
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`);
|
|
88
112
|
const result = await capture({
|
|
89
113
|
url: frame.kind === "html" ? `${origin}/${frame.file}?theme=${encodeURIComponent(theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(frameId)}&theme=${encodeURIComponent(theme)}`,
|
|
90
114
|
width: plan.width,
|
|
91
115
|
height: plan.initialHeight,
|
|
92
|
-
out:
|
|
93
|
-
fullHeight: plan.fullHeight
|
|
116
|
+
out: tmp,
|
|
117
|
+
fullHeight: plan.fullHeight,
|
|
118
|
+
scale
|
|
94
119
|
});
|
|
95
|
-
|
|
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 {
|
|
96
142
|
ok: true,
|
|
97
143
|
path: rel,
|
|
98
144
|
width: result.width,
|
|
99
145
|
height: result.height,
|
|
100
146
|
scale: result.scale,
|
|
101
|
-
...result.truncated ? {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 {}
|
|
106
166
|
}
|
|
107
|
-
/** One capture at a time: shots are seconds apart at most, and a Chrome per
|
|
108
|
-
* request would stampede the machine mid-jam.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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);
|
|
113
175
|
return run;
|
|
114
176
|
}
|
|
115
|
-
|
|
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--;
|
|
116
189
|
const bin = findChrome();
|
|
117
190
|
if (!bin) return {
|
|
118
191
|
ok: false,
|
|
@@ -205,11 +278,14 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
205
278
|
}
|
|
206
279
|
});
|
|
207
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;
|
|
208
282
|
watchdog = setTimeout(() => {
|
|
209
283
|
try {
|
|
210
284
|
chrome.kill("SIGKILL");
|
|
211
285
|
} catch {}
|
|
212
286
|
}, timeoutMs + 15e3);
|
|
287
|
+
const RESERVE = 21e3;
|
|
288
|
+
const discretionary = () => hardBy - RESERVE - Date.now();
|
|
213
289
|
const { targetId } = await send("Target.createTarget", { url: "about:blank" });
|
|
214
290
|
const { sessionId } = await send("Target.attachToTarget", {
|
|
215
291
|
targetId,
|
|
@@ -218,7 +294,7 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
218
294
|
await send("Emulation.setDeviceMetricsOverride", {
|
|
219
295
|
width,
|
|
220
296
|
height,
|
|
221
|
-
deviceScaleFactor:
|
|
297
|
+
deviceScaleFactor: scale,
|
|
222
298
|
mobile: false
|
|
223
299
|
}, sessionId);
|
|
224
300
|
await send("Page.enable", {}, sessionId);
|
|
@@ -244,12 +320,55 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
244
320
|
ok: false,
|
|
245
321
|
error: `the frame never rendered${lastException ? ` - the page threw: ${lastException}` : " (no exception surfaced - is the dev server reachable from this machine?)"}`
|
|
246
322
|
};
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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);
|
|
253
372
|
const errEval = await send("Runtime.evaluate", {
|
|
254
373
|
expression: "window.__mvFrameError || \"\"",
|
|
255
374
|
returnByValue: true
|
|
@@ -259,9 +378,8 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
259
378
|
ok: false,
|
|
260
379
|
error: `the frame rendered an error - ${frameError}`
|
|
261
380
|
};
|
|
262
|
-
let capW = width, capH = height,
|
|
381
|
+
let capW = width, capH = height, truncated = false, note = "";
|
|
263
382
|
if (fullHeight) {
|
|
264
|
-
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
265
383
|
const measureH = async () => {
|
|
266
384
|
const v = (await sendD("Runtime.evaluate", {
|
|
267
385
|
expression: `Math.ceil((document.querySelector('.mv-doc')||document.getElementById('root')||document.body).getBoundingClientRect().height)`,
|
|
@@ -306,46 +424,71 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
306
424
|
h = Math.max(m, capped);
|
|
307
425
|
}
|
|
308
426
|
};
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
await growFit(
|
|
312
|
-
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) {
|
|
313
435
|
scale = 1;
|
|
314
|
-
await growFit(1,
|
|
436
|
+
await growFit(1, capFor(width, 1), settleDeadline);
|
|
315
437
|
}
|
|
316
|
-
const cap = scale
|
|
438
|
+
const cap = capFor(width, scale);
|
|
317
439
|
capW = width;
|
|
318
440
|
if (measured > cap) {
|
|
319
441
|
capH = cap;
|
|
320
442
|
truncated = true;
|
|
321
443
|
note = `frame is ${measured}px tall; captured the top ${cap}px - split it or reduce its height`;
|
|
322
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`;
|
|
323
446
|
await sendD("Emulation.setDeviceMetricsOverride", {
|
|
324
447
|
width: capW,
|
|
325
448
|
height: capH,
|
|
326
449
|
deviceScaleFactor: scale,
|
|
327
450
|
mobile: false
|
|
328
451
|
}, 5e3);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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);
|
|
341
469
|
}
|
|
342
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
|
+
}
|
|
343
486
|
const shot = await sendD("Page.captureScreenshot", {
|
|
344
487
|
format: "png",
|
|
345
488
|
captureBeyondViewport: true,
|
|
346
489
|
clip: {
|
|
347
|
-
x:
|
|
348
|
-
y:
|
|
490
|
+
x: capX,
|
|
491
|
+
y: capY,
|
|
349
492
|
width: capW,
|
|
350
493
|
height: capH,
|
|
351
494
|
scale: 1
|
|
@@ -357,10 +500,8 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
357
500
|
width: capW,
|
|
358
501
|
height: capH,
|
|
359
502
|
scale,
|
|
360
|
-
...truncated ? {
|
|
361
|
-
|
|
362
|
-
note
|
|
363
|
-
} : {}
|
|
503
|
+
...truncated ? { truncated: true } : {},
|
|
504
|
+
...note ? { note } : {}
|
|
364
505
|
};
|
|
365
506
|
} catch (err) {
|
|
366
507
|
return {
|
|
@@ -384,4 +525,4 @@ async function captureNow({ url, width, height, out, fullHeight = false, timeout
|
|
|
384
525
|
}
|
|
385
526
|
}
|
|
386
527
|
//#endregion
|
|
387
|
-
export { shootFrame };
|
|
528
|
+
export { capture, findChrome, shootFrame };
|
package/docs/live-jam.md
CHANGED
|
@@ -98,8 +98,12 @@ obvious one:
|
|
|
98
98
|
`design/.local/shots/<slug>.request.json` with `{"frame":"<id>","theme":"<t>"}`; the dev
|
|
99
99
|
server renders and writes `<slug>.result.json` with the PNG path or an error, which the
|
|
100
100
|
agent Reads.
|
|
101
|
-
- **`npx marver shot <frame
|
|
102
|
-
shell-ful agents - the same renderer, one line.
|
|
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.
|
|
103
107
|
|
|
104
108
|
The generated jam instructions tell every agent to shoot and LOOK before replying "done".
|
|
105
109
|
A frame that never mounts, or a dev server that isn't reachable, comes back as an honest
|
package/docs/publish.md
CHANGED
|
@@ -31,7 +31,10 @@ opens the same view on the published site, as long as its board and frame shippe
|
|
|
31
31
|
|
|
32
32
|
**What ships**: the boards you list, the frames they reference, and the host `public/`
|
|
33
33
|
directory in full (the `--boards` filter covers frames, not public assets). A flow you
|
|
34
|
-
publish must have all its `data-goto` targets on a published board.
|
|
34
|
+
publish must have all its `data-goto` targets on a published board. Sidebar folders ship
|
|
35
|
+
only around published boards (a folder with nothing published never reaches the bundle),
|
|
36
|
+
and `description`s ship only for published things - the project's, the published boards',
|
|
37
|
+
their folders', scenes' and frames'.
|
|
35
38
|
|
|
36
39
|
## Who can open your canvas
|
|
37
40
|
|
package/docs/slides.md
CHANGED
|
@@ -112,9 +112,15 @@ slides and the entrance presets.
|
|
|
112
112
|
`design/theme.css` tokens, strips animation at rest, and lets any
|
|
113
113
|
styling you pass override the theme - so pass data and structure only.
|
|
114
114
|
SVG-rendered, in a lazy chunk chart-free canvases never download.
|
|
115
|
-
- `<Video src="intro.mp4" poster="intro.jpg" />` - the poster is the
|
|
116
|
-
at rest
|
|
117
|
-
|
|
115
|
+
- `<Video src="intro.mp4" poster="intro.jpg" />` - the poster is the frame
|
|
116
|
+
at rest. Omit it on a local clip and marver renders one from the clip's own
|
|
117
|
+
first moments (`intro.mp4.poster.png` beside it - the dev server on first
|
|
118
|
+
sight, `marver build` before publishing; needs Chrome, like `shot`). An
|
|
119
|
+
authored poster always wins. In slides mode the glass strip mounts
|
|
120
|
+
on its own (play/pause, seek, mute, fullscreen); in any other live frame -
|
|
121
|
+
interact mode, play, a published prototype - the poster is the play button.
|
|
122
|
+
`ratio="9 / 16"` for a vertical clip; `autoplay` for a muted ambient loop
|
|
123
|
+
(that frame then stays live on the canvas). Remote https direct files work
|
|
118
124
|
too.
|
|
119
125
|
|
|
120
126
|
## Theme tokens
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -40,30 +40,48 @@ export function sanitizeOption(option: Record<string, unknown>, animate: boolean
|
|
|
40
40
|
return out
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/** The
|
|
44
|
-
*
|
|
45
|
-
function
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const ink = v('--sl-ink', '#18181b')
|
|
49
|
-
const muted = v('--sl-muted', 'rgba(24,24,27,.55)')
|
|
50
|
-
const accent = v('--sl-accent', '#0088ff')
|
|
51
|
-
const font = v('--sl-font', FONT_STACK) // the deck's family, so chart text matches the slide
|
|
43
|
+
/** The chart's palette, pure so the tests own it. `inSlide` picks the slide type scale
|
|
44
|
+
* (18px labels on a 1280-wide stage) over the document/UI scale (12px). */
|
|
45
|
+
export function chartTheme(t: { ink: string; font: string; accent: string; ground: string; grid: string; dark: boolean; inSlide: boolean }) {
|
|
46
|
+
const fs = t.inSlide ? 18 : 12
|
|
47
|
+
const muted = t.dark ? 'rgba(242,242,247,.5)' : 'rgba(28,28,30,.5)'
|
|
52
48
|
return {
|
|
53
|
-
color: [accent, '#7c5cff', '#00b8a9', '#f0883e', '#d6608c', '#5b8def'],
|
|
54
|
-
textStyle: { fontFamily: font, color: ink },
|
|
49
|
+
color: [t.accent, '#7c5cff', '#00b8a9', '#f0883e', '#d6608c', '#5b8def'],
|
|
50
|
+
textStyle: { fontFamily: t.font, color: t.ink },
|
|
55
51
|
axisPointer: { lineStyle: { color: muted } },
|
|
56
|
-
categoryAxis: { axisLine: { lineStyle: { color: muted } }, axisLabel: { color: ink, fontSize:
|
|
57
|
-
valueAxis: { axisLabel: { color: ink, fontSize:
|
|
58
|
-
legend: { textStyle: { color: ink, fontSize:
|
|
52
|
+
categoryAxis: { axisLine: { lineStyle: { color: muted } }, axisLabel: { color: t.ink, fontSize: fs }, splitLine: { show: false } },
|
|
53
|
+
valueAxis: { axisLabel: { color: t.ink, fontSize: fs }, splitLine: { lineStyle: { color: t.grid } } },
|
|
54
|
+
legend: { textStyle: { color: t.ink, fontSize: fs } },
|
|
55
|
+
title: { textStyle: { color: t.ink, fontFamily: t.font }, subtextStyle: { color: muted, fontFamily: t.font } },
|
|
56
|
+
// series labels (pie/funnel/bar values): the frame's ink, no halo - echarts' default paints
|
|
57
|
+
// #333 with a white 2px text border, which reads as outlined glyphs on a dark ground
|
|
58
|
+
label: { color: t.ink, fontSize: fs, textBorderWidth: 0 },
|
|
59
59
|
tooltip: {
|
|
60
|
-
backgroundColor:
|
|
61
|
-
textStyle: { color: ink, fontFamily: font },
|
|
60
|
+
backgroundColor: t.ground, borderColor: 'rgba(127,127,127,.25)',
|
|
61
|
+
textStyle: { color: t.ink, fontFamily: t.font, fontSize: fs === 18 ? 16 : 12 },
|
|
62
62
|
extraCssText: 'border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.14);backdrop-filter:blur(8px)',
|
|
63
63
|
},
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** The house theme, read from the frame the chart sits in, at render time. Ink and font are
|
|
68
|
+
* the element's own COMPUTED color and font-family - so a chart inherits a UI screen's
|
|
69
|
+
* Tailwind text colour and typeface, a Doc's tokens, or a Slide's, with no per-context
|
|
70
|
+
* wiring. Accent and ground come from slide tokens, then Doc tokens, then the mode palette. */
|
|
71
|
+
function houseTheme(el: HTMLElement, dark: boolean) {
|
|
72
|
+
const css = getComputedStyle(el)
|
|
73
|
+
const v = (...names: string[]) => { for (const n of names) { const x = css.getPropertyValue(n).trim(); if (x) return x } return '' }
|
|
74
|
+
return chartTheme({
|
|
75
|
+
ink: css.color || (dark ? '#F2F2F7' : '#1C1C1E'),
|
|
76
|
+
font: v('--sl-font') || css.fontFamily || FONT_STACK,
|
|
77
|
+
accent: v('--sl-accent', '--mv-accent') || (dark ? '#0091FF' : '#0088FF'),
|
|
78
|
+
ground: v('--sl-ground', '--mv-surface', '--mv-bg') || (dark ? '#1C1C1E' : '#FFFFFF'),
|
|
79
|
+
grid: v('--sl-grid') || (dark ? 'rgba(242,242,247,.12)' : 'rgba(28,28,30,.1)'),
|
|
80
|
+
dark,
|
|
81
|
+
inSlide: !!el.closest('.sl-root'),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
67
85
|
/** The frame's visual theme (light/dark), observed the same way the play
|
|
68
86
|
* flag is - the stage flips documentElement class/data-theme on sh:set-theme
|
|
69
87
|
* and a themed chart must follow, not stay stale. */
|
|
@@ -81,20 +99,40 @@ export function Chart({ option, h = 420 }: { option: Record<string, unknown>; h?
|
|
|
81
99
|
const play = useSlidePlay()
|
|
82
100
|
const theme = useFrameTheme()
|
|
83
101
|
const [failed, setFailed] = useState(false)
|
|
102
|
+
// The instance lives in a ref: init/dispose follows the THEME and the play flip (a theme
|
|
103
|
+
// object per init, the entrance on play); the option rides a separate effect that calls
|
|
104
|
+
// setOption on the live instance - so a parent re-render, or an HMR edit to a formatter
|
|
105
|
+
// function, never disposes and re-inits the chart.
|
|
106
|
+
const chartRef = useRef<import('./chart-engine.ts').EChartsInstance | null>(null)
|
|
107
|
+
const optionRef = useRef(option)
|
|
108
|
+
optionRef.current = option
|
|
109
|
+
const playRef = useRef(play)
|
|
110
|
+
playRef.current = play
|
|
84
111
|
useEffect(() => {
|
|
85
112
|
const el = ref.current
|
|
86
113
|
if (!el) return
|
|
87
114
|
let disposed = false
|
|
88
|
-
let
|
|
115
|
+
let ro: ResizeObserver | null = null
|
|
89
116
|
void loadEngine().then((engine) => {
|
|
90
117
|
if (disposed || !ref.current) return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
chart.setOption(sanitizeOption(
|
|
118
|
+
const chart = engine.init(ref.current, houseTheme(ref.current, theme === 'dark'), { renderer: 'svg' })
|
|
119
|
+
chartRef.current = chart
|
|
120
|
+
chart.setOption(sanitizeOption(optionRef.current, playRef.current))
|
|
121
|
+
// a slide is a fixed stage, but a UI screen or a Doc reflows (device pills, responsive
|
|
122
|
+
// layouts): follow the box, or the SVG keeps its mount-time size
|
|
123
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
124
|
+
ro = new ResizeObserver(() => { if (!disposed) chart.resize() })
|
|
125
|
+
ro.observe(ref.current)
|
|
126
|
+
}
|
|
94
127
|
}).catch(() => setFailed(true))
|
|
95
|
-
return () => { disposed = true;
|
|
96
|
-
|
|
97
|
-
|
|
128
|
+
return () => { disposed = true; ro?.disconnect(); chartRef.current?.dispose(); chartRef.current = null }
|
|
129
|
+
}, [play, theme])
|
|
130
|
+
// option changes (content OR a function inside it) reach the live instance: a normal merge
|
|
131
|
+
// that REPLACES the series list (a removed series disappears) while a viewer's dataZoom,
|
|
132
|
+
// legend selection and the like survive - notMerge would reset them on every parent render
|
|
133
|
+
useEffect(() => { chartRef.current?.setOption(sanitizeOption(option, play), { replaceMerge: ['series'] }) }, [option, play])
|
|
98
134
|
if (failed) return <div className="mv-block mv-imgerr"><b>chart unavailable</b><span>echarts failed to load</span></div>
|
|
99
|
-
|
|
135
|
+
// contain: inline-size - echarts sizes its inner box in px, which would otherwise pin the
|
|
136
|
+
// author's flex/grid column at that width (min-content) and defeat the ResizeObserver above
|
|
137
|
+
return <div ref={ref} className="mv-block mv-chart" style={{ width: '100%', height: h, minWidth: 0, contain: 'inline-size' }} />
|
|
100
138
|
}
|
|
@@ -176,8 +176,11 @@ body { margin: 0; }
|
|
|
176
176
|
.mv-col { display: flex; flex-direction: column; min-width: 0; }
|
|
177
177
|
|
|
178
178
|
/* the rubber: diagram + image blocks own their breathing room. The surface is
|
|
179
|
-
a whisper, not a card - the content pops, the block only frames it
|
|
180
|
-
|
|
179
|
+
a whisper, not a card - the content pops, the block only frames it. The card is a
|
|
180
|
+
DOCUMENT treatment: inside a Slide or a UI screen a block is bare (the author's own
|
|
181
|
+
card or the slide's grid frames it), so it takes no invisible ${UNIT}px of padding there. */
|
|
182
|
+
.mv-block { margin: 0; }
|
|
183
|
+
.mv-doc .mv-block { padding: ${UNIT}px; border: 1px solid var(--mv-block-line);
|
|
181
184
|
border-radius: 10px; background: var(--mv-block-bg); }
|
|
182
185
|
/* an image block is NOT a card: the screenshot IS the content. Drop the surface + border so we don't
|
|
183
186
|
frame a frame; give the image itself a hairline edge and a whisper of shadow so it reads as a clean,
|