@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +7 -4
  3. package/dist/boards-6hKVW42a.mjs +57 -0
  4. package/dist/boards-BdG1TJwU.mjs +267 -0
  5. package/dist/{build-ByYafIhj.mjs → build-Ct0iMoSi.mjs} +77 -30
  6. package/dist/cli.mjs +15 -6
  7. package/dist/{daemon-Bfucyf1o.mjs → daemon-Cb4JjpgL.mjs} +1 -1
  8. package/dist/{dev-yZMyQeUj.mjs → dev-CdcIuhZJ.mjs} +14 -4
  9. package/dist/{init-Dvaso7YO.mjs → init-BQIpIpIv.mjs} +6 -3
  10. package/dist/{manifest-DvOmglFp.mjs → manifest-CpbsqQ_v.mjs} +94 -17
  11. package/dist/{plugin-BsmG5i2X.mjs → plugin-BXyezwfN.mjs} +201 -74
  12. package/dist/poster-DOY7pax8.mjs +143 -0
  13. package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
  14. package/dist/{shot-kbR_xzJH.mjs → shot-CwmHO5T4.mjs} +197 -56
  15. package/docs/live-jam.md +6 -2
  16. package/docs/publish.md +4 -1
  17. package/docs/slides.md +9 -3
  18. package/package.json +1 -1
  19. package/src/client/content/chart.tsx +62 -24
  20. package/src/client/content/index.tsx +5 -2
  21. package/src/client/content/video.tsx +132 -35
  22. package/src/client/frame-host/bridge.js +6 -1
  23. package/src/client/shell/App.tsx +33 -247
  24. package/src/client/shell/BoardList.tsx +408 -0
  25. package/src/client/shell/ContextMenu.tsx +59 -0
  26. package/src/client/shell/icons.tsx +7 -0
  27. package/src/client/shell/store.ts +156 -48
  28. package/src/client/shell/styles.css +42 -4
  29. package/src/shared/board-tree.ts +285 -0
  30. package/templates/AGENTS-embedded.md +35 -7
  31. package/templates/AGENTS-studio.md +35 -7
  32. package/templates/instructions/boards.md +120 -7
  33. package/templates/instructions/craft.md +21 -0
  34. package/templates/instructions/discover.md +7 -2
  35. package/templates/instructions/iterate.md +114 -18
  36. package/templates/instructions/jam.md +18 -2
  37. package/templates/instructions/review.md +4 -0
  38. package/templates/instructions/shape.md +16 -2
  39. package/templates/instructions/slides.md +5 -1
  40. package/templates/instructions/welcome.md +4 -1
  41. package/templates/instructions/wireframe.md +3 -0
  42. 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
- function planShot(frame, viewports) {
39
- const cw = typeof frame.contentWidth === "number" && Number.isFinite(frame.contentWidth) && frame.contentWidth > 0 ? frame.contentWidth : void 0;
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
- const rel = `design/.local/shots/${slug(frameId)}--${theme}.png`;
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: join(root, rel),
93
- fullHeight: plan.fullHeight
116
+ out: tmp,
117
+ fullHeight: plan.fullHeight,
118
+ scale
94
119
  });
95
- return result.ok ? {
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
- truncated: true,
103
- note: result.note
104
- } : {}
105
- } : result;
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 concurrent
108
- * request would stampede the machine mid-jam. */
109
- let chain = Promise.resolve();
110
- function capture(req) {
111
- const run = chain.then(() => captureNow(req), () => captureNow(req));
112
- chain = run;
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
- async function captureNow({ url, width, height, out, fullHeight = false, timeoutMs = 3e4 }) {
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: 2,
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
- await send("Runtime.evaluate", {
248
- expression: "document.fonts.ready.then(() => true)",
249
- awaitPromise: true,
250
- returnByValue: true
251
- }, sessionId).catch(() => null);
252
- await new Promise((r2) => setTimeout(r2, 250));
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, scale = 2, truncated = false, note = "";
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 CAP2 = 8192, CAP1 = 16384;
310
- const settleDeadline = Date.now() + 6e3;
311
- await growFit(2, CAP2, settleDeadline);
312
- if (measured > CAP2) {
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, CAP1, settleDeadline);
436
+ await growFit(1, capFor(width, 1), settleDeadline);
315
437
  }
316
- const cap = scale === 2 ? CAP2 : CAP1;
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
- if (Date.now() < settleDeadline) {
330
- await sendD("Runtime.evaluate", {
331
- expression: `window.postMessage({type:'sh:camera',moving:false,scale:1}, location.origin)`,
332
- returnByValue: true
333
- }).catch(() => null);
334
- await wait(300);
335
- for (let j = 0; j < 20 && Date.now() < settleDeadline && !await lodIdle(); j++) await wait(100);
336
- await sendD("Runtime.evaluate", {
337
- expression: "document.fonts.ready.then(()=>true)",
338
- awaitPromise: true,
339
- returnByValue: true
340
- }).catch(() => null);
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: 0,
348
- y: 0,
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
- truncated: true,
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>`** / `GET /api/shot?frame=<id>&theme=<t>` for humans and
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 slide
116
- at rest (required for local files); in slides mode the glass strip plays
117
- it (play/pause, seek, mute, fullscreen). Remote https direct files work
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.14.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 house theme, from the slide tokens at render time (computed style so
44
- * host overrides and dark scheme are both honored). */
45
- function houseTheme(el: HTMLElement) {
46
- const css = getComputedStyle(el)
47
- const v = (name: string, fb: string) => css.getPropertyValue(name).trim() || fb
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: 18 }, splitLine: { show: false } },
57
- valueAxis: { axisLabel: { color: ink, fontSize: 18 }, splitLine: { lineStyle: { color: v('--sl-grid', 'rgba(127,127,127,.15)') } } },
58
- legend: { textStyle: { color: ink, fontSize: 18 } },
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: v('--sl-ground', '#fff'), borderColor: 'rgba(127,127,127,.25)',
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 chart: import('./chart-engine.ts').EChartsInstance | null = null
115
+ let ro: ResizeObserver | null = null
89
116
  void loadEngine().then((engine) => {
90
117
  if (disposed || !ref.current) return
91
- // a theme OBJECT per init - never a stale global registration
92
- chart = engine.init(ref.current, houseTheme(ref.current), { renderer: 'svg' })
93
- chart.setOption(sanitizeOption(option, play))
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; chart?.dispose() }
96
- // re-init on play flip (the entrance) and on theme flip (fresh tokens)
97
- }, [option, play, theme])
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
- return <div ref={ref} className="mv-block mv-chart" style={{ width: '100%', height: h }} />
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
- .mv-block { margin: 0; padding: ${UNIT}px; border: 1px solid var(--mv-block-line);
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,