@marver-design/marver 0.10.0 → 0.10.2

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.
@@ -34,6 +34,29 @@ function findChrome() {
34
34
  return CHROMES.find((p) => existsSync(p)) ?? null;
35
35
  }
36
36
  const slug = (frameId) => frameId.replace(/\//g, "--");
37
+ 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;
40
+ const vpObj = frame.viewport ? viewports[frame.viewport] : void 0;
41
+ const fallback = viewports.mobile ?? {
42
+ width: 390,
43
+ height: 844
44
+ };
45
+ if (cw) {
46
+ const width = clamp(vpObj?.width ?? cw, 320, 1600);
47
+ return {
48
+ width,
49
+ initialHeight: Math.round(width * .75),
50
+ fullHeight: true
51
+ };
52
+ }
53
+ const vp = vpObj ?? fallback;
54
+ return {
55
+ width: vp.width,
56
+ initialHeight: vp.height,
57
+ fullHeight: false
58
+ };
59
+ }
37
60
  /** Resolve a frame from the manifest and screenshot it - the shared core behind BOTH
38
61
  * transports (the HTTP /api/shot endpoint and the file-drop inbox), so they validate and
39
62
  * name output identically. `origin` is the dev server's own base URL. */
@@ -52,24 +75,27 @@ async function shootFrame(opts) {
52
75
  ok: false,
53
76
  error: `unknown frame "${frameId}" - ids are in design/manifest.json`
54
77
  };
55
- const vp = viewports[frame.viewport ?? ""] ?? viewports.mobile ?? {
56
- width: 390,
57
- height: 844
58
- };
78
+ const plan = planShot(frame, viewports);
59
79
  const shotsDir = join(root, "design", ".local", "shots");
60
80
  mkdirSync(shotsDir, { recursive: true });
61
81
  const rel = `design/.local/shots/${slug(frameId)}--${theme}.png`;
62
82
  const result = await capture({
63
83
  url: frame.kind === "html" ? `${origin}/${frame.file}?theme=${encodeURIComponent(theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(frameId)}&theme=${encodeURIComponent(theme)}`,
64
- width: vp.width,
65
- height: vp.height,
66
- out: join(root, rel)
84
+ width: plan.width,
85
+ height: plan.initialHeight,
86
+ out: join(root, rel),
87
+ fullHeight: plan.fullHeight
67
88
  });
68
89
  return result.ok ? {
69
90
  ok: true,
70
91
  path: rel,
71
- width: vp.width,
72
- height: vp.height
92
+ width: result.width,
93
+ height: result.height,
94
+ scale: result.scale,
95
+ ...result.truncated ? {
96
+ truncated: true,
97
+ note: result.note
98
+ } : {}
73
99
  } : result;
74
100
  }
75
101
  /** One capture at a time: shots are seconds apart at most, and a Chrome per concurrent
@@ -80,7 +106,7 @@ function capture(req) {
80
106
  chain = run;
81
107
  return run;
82
108
  }
83
- async function captureNow({ url, width, height, out, timeoutMs = 3e4 }) {
109
+ async function captureNow({ url, width, height, out, fullHeight = false, timeoutMs = 3e4 }) {
84
110
  const bin = findChrome();
85
111
  if (!bin) return {
86
112
  ok: false,
@@ -172,6 +198,7 @@ async function captureNow({ url, width, height, out, timeoutMs = 3e4 }) {
172
198
  rej(e);
173
199
  }
174
200
  });
201
+ const sendD = (method, params, ms = 1500) => Promise.race([send(method, params, sessionId), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error(`${method} timed out`)), ms))]);
175
202
  watchdog = setTimeout(() => {
176
203
  try {
177
204
  chrome.kill("SIGKILL");
@@ -226,9 +253,109 @@ async function captureNow({ url, width, height, out, timeoutMs = 3e4 }) {
226
253
  ok: false,
227
254
  error: `the frame rendered an error - ${frameError}`
228
255
  };
229
- const shot = await send("Page.captureScreenshot", { format: "png" }, sessionId);
256
+ let capW = width, capH = height, scale = 2, truncated = false, note = "";
257
+ if (fullHeight) {
258
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
259
+ const measureH = async () => {
260
+ const v = (await sendD("Runtime.evaluate", {
261
+ expression: `Math.ceil((document.querySelector('.mv-doc')||document.getElementById('root')||document.body).getBoundingClientRect().height)`,
262
+ returnByValue: true
263
+ }).catch(() => null))?.result?.value;
264
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
265
+ };
266
+ const lodIdle = async () => {
267
+ const v = (await sendD("Runtime.evaluate", {
268
+ expression: `(typeof window.__mvLodBusy==='function')?window.__mvLodBusy():0`,
269
+ returnByValue: true
270
+ }).catch(() => null))?.result?.value;
271
+ return typeof v === "number" ? v === 0 : true;
272
+ };
273
+ let measured = 0;
274
+ const growFit = async (dsf, capCss, deadline) => {
275
+ let h = Math.max(height, 2e3), prev = -1;
276
+ for (let i = 0; i < 8 && Date.now() < deadline; i++) {
277
+ await sendD("Emulation.setDeviceMetricsOverride", {
278
+ width,
279
+ height: Math.min(h, capCss),
280
+ deviceScaleFactor: dsf,
281
+ mobile: false
282
+ }).catch(() => {});
283
+ if (Date.now() >= deadline) break;
284
+ await sendD("Runtime.evaluate", {
285
+ expression: "document.fonts.ready.then(()=>true)",
286
+ awaitPromise: true,
287
+ returnByValue: true
288
+ }).catch(() => null);
289
+ if (Date.now() >= deadline) break;
290
+ for (let j = 0; j < 10 && Date.now() < deadline && !await lodIdle(); j++) await wait(100);
291
+ if (Date.now() >= deadline) break;
292
+ await wait(150);
293
+ if (Date.now() >= deadline) break;
294
+ const m = await measureH();
295
+ if (m > 0) measured = m;
296
+ const capped = Math.min(h, capCss);
297
+ if (capped >= capCss) break;
298
+ if (m > 0 && m <= capped && m === prev) break;
299
+ prev = m;
300
+ h = Math.max(m, capped);
301
+ }
302
+ };
303
+ const CAP2 = 8192, CAP1 = 16384;
304
+ const settleDeadline = Date.now() + 6e3;
305
+ await growFit(2, CAP2, settleDeadline);
306
+ if (measured > CAP2) {
307
+ scale = 1;
308
+ await growFit(1, CAP1, settleDeadline);
309
+ }
310
+ const cap = scale === 2 ? CAP2 : CAP1;
311
+ capW = width;
312
+ if (measured > cap) {
313
+ capH = cap;
314
+ truncated = true;
315
+ note = `frame is ${measured}px tall; captured the top ${cap}px - split it or reduce its height`;
316
+ } else capH = clamp(measured || height, 80, cap);
317
+ await sendD("Emulation.setDeviceMetricsOverride", {
318
+ width: capW,
319
+ height: capH,
320
+ deviceScaleFactor: scale,
321
+ mobile: false
322
+ }, 5e3);
323
+ if (Date.now() < settleDeadline) {
324
+ await sendD("Runtime.evaluate", {
325
+ expression: `window.postMessage({type:'sh:camera',moving:false,scale:1}, location.origin)`,
326
+ returnByValue: true
327
+ }).catch(() => null);
328
+ await wait(300);
329
+ for (let j = 0; j < 20 && Date.now() < settleDeadline && !await lodIdle(); j++) await wait(100);
330
+ await sendD("Runtime.evaluate", {
331
+ expression: "document.fonts.ready.then(()=>true)",
332
+ awaitPromise: true,
333
+ returnByValue: true
334
+ }).catch(() => null);
335
+ }
336
+ }
337
+ const shot = await sendD("Page.captureScreenshot", {
338
+ format: "png",
339
+ captureBeyondViewport: true,
340
+ clip: {
341
+ x: 0,
342
+ y: 0,
343
+ width: capW,
344
+ height: capH,
345
+ scale: 1
346
+ }
347
+ }, 15e3);
230
348
  writeFileSync(out, Buffer.from(String(shot.data), "base64"));
231
- return { ok: true };
349
+ return {
350
+ ok: true,
351
+ width: capW,
352
+ height: capH,
353
+ scale,
354
+ ...truncated ? {
355
+ truncated: true,
356
+ note
357
+ } : {}
358
+ };
232
359
  } catch (err) {
233
360
  return {
234
361
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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,
@@ -35,6 +35,12 @@ const schedule = (job: () => Promise<void>): void => {
35
35
  pump()
36
36
  }
37
37
 
38
+ // Decode-idle signal for the headless shot: 0 means no decode is running or queued. The shot
39
+ // (src/server/shot.ts) polls this to know each image's first decode has settled - which pins
40
+ // its aspect-ratio and so stabilizes the frame's measured height - before it measures and
41
+ // captures. Harmless and 0 when there are no LOD images (nothing ever decodes).
42
+ if (typeof window !== 'undefined') (window as { __mvLodBusy?: () => number }).__mvLodBusy = () => active + q.length
43
+
38
44
  interface Item { canvas: HTMLCanvasElement; src: string; bucket: number; token: number }
39
45
  const items = new Set<Item>()
40
46
  let scale = 0.2 // overview default until the shell primes the settled scale on frame-ready
@@ -73,7 +73,9 @@ window.addEventListener('message', (e) => {
73
73
  })
74
74
 
75
75
  if (isHtmlFrame) {
76
- const ready = () => post({ type: 'sh:ready', id })
76
+ // stamp the URL revision (see main.tsx) so the shell can drop a ready from a superseded document
77
+ const gen = new URLSearchParams(location.search).get('r') ?? ''
78
+ const ready = () => post({ type: 'sh:ready', id, gen })
77
79
  document.readyState === 'loading' ? addEventListener('DOMContentLoaded', ready) : ready()
78
80
  }
79
81
 
@@ -77,7 +77,10 @@ async function boot() {
77
77
  for (const W of wrappers.reverse()) if (W != null) tree = createElement(W, null, tree)
78
78
 
79
79
  createRoot(document.getElementById('root')!).render(createElement(Boundary, null, tree))
80
- post({ type: 'sh:ready', id, meta: frameMod.meta && typeof frameMod.meta === 'object' ? frameMod.meta : undefined })
80
+ // stamp the URL revision so the shell can drop a ready queued by a superseded document (one it
81
+ // auto-renavigated past) - a WindowProxy survives navigation, so a stale ready could otherwise
82
+ // mark a reloading frame ready. Mirrors the sh:measure generation guard.
83
+ post({ type: 'sh:ready', id, gen: params.get('r') ?? '', meta: frameMod.meta && typeof frameMod.meta === 'object' ? frameMod.meta : undefined })
81
84
  } catch (err) {
82
85
  fail((err as Error).message)
83
86
  }