@marver-design/marver 0.14.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 CHANGED
@@ -2,6 +2,95 @@
2
2
 
3
3
  Notable changes to `@marver-design/marver`. Format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver.
4
4
 
5
+ ## 0.15.0 - 2026-09-02
6
+
7
+ ### Added
8
+
9
+ - **Copy frame as image.** The floating toolbar gains an images-square button
10
+ (right of Copy path): one click puts the selected frame on the clipboard as
11
+ a **2x PNG**, rendered by the dev server's headless Chrome - the same picture
12
+ `marver shot` gives an agent. `i` is the shortcut; `⇧i` (or shift-click)
13
+ renders at **4x** (a slide is 5120×2880). The icon breathes while the render
14
+ runs and flashes a check on success, like Copy path. Sized to the node: a
15
+ frame resized to Laptop copies as Laptop; content frames capture in full;
16
+ slides are always the 1280×720 artwork. Dev canvases only - a published
17
+ container has no browser - and one frame at a time.
18
+ - **`marver shot --scale <1-4>`** and `scale=`/`w=`/`h=`/`format=png` on
19
+ `/api/shot`. A frame too tall for the asked scale steps down (4 → 2 → 1)
20
+ inside a 16384px-per-side / 64M-pixel capture budget and reports it in
21
+ `note`; the file name carries the scale actually used (`…@4x.png`), so a
22
+ 4x file always holds 4x pixels.
23
+ - **Render settle.** Every shot (not only content frames) now waits - bounded
24
+ to 3 s, and again in the final viewport of a full-height capture - for fonts,
25
+ in-viewport images, LOD decodes, echarts instances and mermaid diagrams before
26
+ capturing, so a slide with a chart or a photo is never shot half-drawn. Shot
27
+ file names: a default request keeps its unsuffixed name whatever scale the
28
+ capture settled on; an explicit scale gets `@4x`, or `@4x-as-2x` when it had
29
+ to step down, so nothing an agent reads moves and `@4x` never lies.
30
+
31
+ ### Changed
32
+
33
+ - **Three standing rules for the agent, from dogfooding** (`AGENTS.md`,
34
+ `boards.md`, `iterate.md`, `jam.md` - refreshed by `marver init`):
35
+ - **One horizontal band by default.** Every curated board carries a `layout`
36
+ recipe with the scenes side by side; a second band only when the agent can
37
+ say why the eye should move down, with a gap that reads as "below". (Without
38
+ a recipe the shell stacks every scene as its own row.)
39
+ - **Look sideways.** A pin or a pasted pointer marks where the human noticed
40
+ a problem, not the only place it lives: the agent checks the board's live
41
+ sibling frames, fixes the same defect in the same pass and names the frames,
42
+ or asks in-thread whether to roll it out - never one frame fixed and its
43
+ siblings left wrong. History (`archive/`, versions) is never a sibling.
44
+ - **Version the scene before a round.** A round of feedback on a reviewed
45
+ scene starts with a snapshot, `design/scenes/<scene>-v<N>/`, pinned as its
46
+ own band on the `archive` board (oldest at the top), with `data-goto`,
47
+ `goto:` links and `meta.of` re-pointed so the version plays on its own; then
48
+ the live frames are edited in place and threads answered. One snapshot per
49
+ round, git commits per version on offer, staging only the round's paths.
50
+ Rollback is a copy back; the archive is the proof of work.
51
+
52
+ ### Fixed
53
+
54
+ - **Charts everywhere, not only on slides.** `Chart` now takes its ink and
55
+ typeface from the frame it sits in (a UI screen's Tailwind colour and font, a
56
+ Doc's tokens, a Slide's), sizes its type to the context (12px labels in a
57
+ screen or document, 18px on a slide), paints series labels in that ink with
58
+ no halo, follows the layout on resize (a chart no longer pins its flex/grid
59
+ column at mount width), and keys on the option's content so a parent
60
+ re-render never re-initialises it (a viewer's dataZoom or legend selection
61
+ survives, and a formatter edit reaches the live chart). Before this a chart
62
+ in a dark screen or dark spec drew dark-on-dark axis text at slide scale.
63
+ - **Video plays everywhere, not only on slides.** The player used to mount
64
+ only under the slides-mode contract, so a `Video` in a screen, a spec or a
65
+ published prototype was a poster forever. Now the poster is the play button
66
+ wherever the frame is live (interact, play, focus, published); slides keep
67
+ the auto-mounted player, and leaving interact mode on the canvas disarms a
68
+ playing clip so the frame goes still again. New: `ratio="9 / 16"` for
69
+ vertical clips, and `autoplay` for a muted ambient loop (an explicit choice -
70
+ that frame stays live on the canvas). The player's glyphs are Phosphor
71
+ (play, pause, speaker, corners). `shape.md` and `craft.md` teach it.
72
+ - **Posters render themselves.** A local clip without `poster` no longer fails
73
+ the build: marver renders `<clip>.poster.png` beside it from the clip's own
74
+ first moments (at 0.5 s, past the usual black opening) in the same headless
75
+ Chrome the shot renderer uses - the dev server on first sight (the frame
76
+ asks when its conventional poster is missing), `marver build` before assets
77
+ are copied, and `shot` before a frame's first capture. Posters render on
78
+ their own capture lane, so a frame's poster never waits behind the shot that
79
+ needs it. An authored poster always wins; without Chrome the build says
80
+ exactly which file to add.
81
+ - **A screen with a chart is still a screen.** Importing anything from
82
+ `@marver-design/marver/content` used to turn the frame into a content
83
+ document (spec badge, measured height, no device). Content frames are now
84
+ those that render `Doc`, `Md`, `Diagram` or `Img`; `Chart`, `Video`, `Row`,
85
+ `Col` and `Space` are shared blocks a screen or a slide uses freely. The
86
+ block card (padding, hairline, surface) is a document treatment: inside a
87
+ slide or a screen a block is bare, so charts no longer carry 32px of
88
+ invisible padding there. `shape.md` and `craft.md` now teach `Chart`.
89
+ - **Dev server behind a symlinked path.** `marver dev` on a repo reached
90
+ through a symlink (`/tmp` → `/private/tmp`, a linked Dropbox folder) refused
91
+ to serve its own frames ("outside of Vite serving allow list"); the allow
92
+ list now carries the realpath too.
93
+
5
94
  ## 0.14.0 - 2026-09-02
6
95
 
7
96
  ### Added
package/README.md CHANGED
@@ -45,6 +45,8 @@ Frames appear on the canvas the moment the files land. That's the loop.
45
45
  - **Prototype links.** `data-goto="scene/frame"` on any element links frames into a walkable prototype - across boards, too.
46
46
  - **Five ways to view a board.** The canvas (frames on a plane), the board (the same, tidy), **present** (`p`: a full-screen clickable walkthrough - `data-goto` navigates, arrows step, `[` / `]` cycle variants, laser, comments, theme and device pickers in the toolbar), **focus** (one frame as a document - the reading preset for specs), and **slides** (a deck). A published board names its landing view; a frame deep link opens straight into it.
47
47
  - **Content frames.** Specs, Mermaid diagrams, mood boards, and slides live on the same canvas as the screens - import `Doc`, `Md`, `Diagram`, `Img`, `Slide`, `Chart`, `Video` from `@marver-design/marver/content` and think a feature through before any pixels exist. Works in a repo with no app at all: idea first, design second.
48
+ - **Charts and video in any frame.** `Chart` (Apache ECharts, SVG, still at rest) inherits the ink, typeface and accent of whatever frame it sits in - a Tailwind dashboard, a dark spec, a slide - sizes its type to the context and follows the layout on resize. `Video` is poster-first everywhere: click to play wherever the frame is live, `autoplay` for an ambient loop, `ratio` for vertical clips; omit the poster and marver renders one from the clip. A screen with a chart or a clip is still a screen.
49
+ - **Copy as image.** Select a frame, press `i` - a 2x PNG of it lands on the clipboard, rendered by the same headless Chrome that serves `marver shot`; `⇧i` for 4x (a slide is 5120×2880). Paste into Slack, a doc, or a chat with your agent.
48
50
 
49
51
  ## Slides
50
52
 
@@ -82,7 +84,7 @@ The trust boundary is hard: only comments written on the owner's machine trigger
82
84
 
83
85
  ## Working state
84
86
 
85
- The same glow, driven from the terminal. When your agent takes a request, it creates the frame files first, pins them on a board, and runs `npx marver work start <scene/frame ...>` - you see the work land on the canvas in seconds, watch it shimmer while subagents build in parallel, and see it settle on `work done`. Marks self-expire, so a crashed agent never leaves a frame glowing. And `npx marver shot <scene/frame>` renders one frame headless to a PNG, so the agent can look at what it built before it says it is done.
87
+ The same glow, driven from the terminal. When your agent takes a request, it creates the frame files first, pins them on a board, and runs `npx marver work start <scene/frame ...>` - you see the work land on the canvas in seconds, watch it shimmer while subagents build in parallel, and see it settle on `work done`. Marks self-expire, so a crashed agent never leaves a frame glowing. And `npx marver shot <scene/frame> [--scale 4]` renders one frame headless to a PNG, so the agent can look at what it built before it says it is done - the same picture you get from the canvas's copy-as-image.
86
88
 
87
89
  ## Commands
88
90
 
@@ -95,7 +97,7 @@ The same glow, driven from the terminal. When your agent takes a request, it cre
95
97
  | `npx marver share …` | The roster (owner): `add <who> [--role]` · `remove` · `block` / `unblock` · `general <mode>` · `list` · `requests` · `explain <who>` · `who` |
96
98
  | `npx marver comments …` | The agent's queue: `connect <url>` · `sync` · `list` · `reply` · `resolve` · `invite <email>` · `revoke <email>` |
97
99
  | `npx marver work …` | Working glow from the terminal: `start <scene/frame …>` · `done … \| --all` · `list` |
98
- | `npx marver shot <frame>` | Render one frame headless and print the PNG path (needs `dev` running) |
100
+ | `npx marver shot <frame> [--scale 1-4]` | Render one frame headless and print the PNG path (needs `dev` running); 2x by default |
99
101
 
100
102
  ## Shortcuts
101
103
 
@@ -108,7 +110,7 @@ The same glow, driven from the terminal. When your agent takes a request, it cre
108
110
 
109
111
  **Board & chrome** - `t` tidy · `d` toggle light/dark for the board · `⌘\` (ctrl+\) collapse/open sidebar.
110
112
 
111
- **Selection** - click selects · shift+click (canvas or sidebar) builds a multi-selection · `⌘A` selects every frame on the board · `⇧P` copies the selected frames' paths (board, frame, and file) · double-click enters interact mode (`esc` or click outside leaves) · drag the title bar to move, edges to resize (widths snap to devices).
113
+ **Selection** - click selects · shift+click (canvas or sidebar) builds a multi-selection · `⌘A` selects every frame on the board · `⇧P` copies the selected frames' paths (board, frame, and file) · `i` copies the selected frame to the clipboard as a 2x PNG (`⇧i` for 4x; also the images-square button in the floating toolbar - dev canvases only, the renderer is the dev server's headless Chrome) · double-click enters interact mode (`esc` or click outside leaves) · drag the title bar to move, edges to resize (widths snap to devices).
112
114
 
113
115
  **Modes** - `c` comment mode · `l` laser mode · `⇧C` hide/show comment pins · `⇧L` laser comment (spotlight a thread's element) · `p` play (present, or slides on a slides board) · `h` hide all chrome.
114
116
 
@@ -1,6 +1,6 @@
1
1
  import { i as ROUTE, n as NAME } from "./cli.mjs";
2
- import { l as detectHost, r as scanFrames, s as loadConfig } from "./manifest-DvOmglFp.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BsmG5i2X.mjs";
2
+ import { l as detectHost, r as scanFrames, s as loadConfig } from "./manifest-BzxSMoDB.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DJyjmQeh.mjs";
4
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -44,8 +44,9 @@ function scanAssetRefs(src, moduleId) {
44
44
  const srcM = /\bsrc\s*=\s*["']([^"']+)["']/.exec(tag);
45
45
  const posterM = /\bposter\s*=\s*["']([^"']+)["']/.exec(tag);
46
46
  if (srcM) {
47
- if (!/^https:\/\//.test(srcM[1]) && !posterM) throw new Error(`${moduleId}: <Video src="${srcM[1]}"> is a local file without a poster - the poster is the slide at rest. Add poster="...".`);
47
+ const remote = /^https:\/\//.test(srcM[1]);
48
48
  out.push(srcM[1]);
49
+ if (!remote && !posterM) out.push(`${srcM[1]}.poster.png`);
49
50
  }
50
51
  if (posterM) out.push(posterM[1]);
51
52
  }
@@ -458,6 +459,13 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
458
459
  }
459
460
  let copiedAssets = 0;
460
461
  const realAssets = existsSync(assetsDir) ? realpathSync(assetsDir) : null;
462
+ for (const r of refs) {
463
+ if (!isLocalAssetRef(r) || !r.endsWith(".poster.png") || existsSync(join(assetsDir, r))) continue;
464
+ const { ensurePoster } = await import("./poster-CbpzSzJu.mjs");
465
+ const g = await ensurePoster(assetsDir, r.slice(0, -11));
466
+ if (!g.ok) throw new Error(`design/assets/${r}: ${g.error}`);
467
+ console.log(` poster: rendered design/assets/${r} (${g.width}×${g.height})`);
468
+ }
461
469
  for (const r of refs) {
462
470
  if (!isLocalAssetRef(r)) continue;
463
471
  const srcFile = join(assetsDir, r);
@@ -526,4 +534,4 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
526
534
  console.log(`\n serve it: npx ${NAME} serve (set MARVER_PASSWORD to gate it)\n`);
527
535
  }
528
536
  //#endregion
529
- export { buildSite };
537
+ export { buildSite, isLocalAssetRef, scanAssetRefs };
package/dist/cli.mjs CHANGED
@@ -53,14 +53,14 @@ function version() {
53
53
  }
54
54
  const cli = cac(NAME);
55
55
  cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "studio | embedded", { default: "studio" }).option("--no-demo", "Skip the demo scene (the demo ships unless this flag is passed)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
56
- const { init } = await import("./init-Dvaso7YO.mjs");
56
+ const { init } = await import("./init-QKNi9gvF.mjs");
57
57
  init(resolve(opts.root), {
58
58
  mode: opts.mode === "embedded" ? "embedded" : "studio",
59
59
  demo: opts.demo !== false
60
60
  });
61
61
  });
62
62
  for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot reload, comments, Live Jam)"], ["canvas", "Start the local canvas - same as dev"]]) cli.command(name, desc).option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
63
- const { dev } = await import("./dev-yZMyQeUj.mjs");
63
+ const { dev } = await import("./dev-BxCmeU_H.mjs");
64
64
  let port;
65
65
  if (opts.port !== void 0) {
66
66
  const n = Number(opts.port);
@@ -70,7 +70,7 @@ for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot
70
70
  await dev(resolve(opts.root), port);
71
71
  });
72
72
  cli.command("build", "Static export → design/.dist (what ships comes from design/publish.json - publishing is default-closed)").option("--boards <names>", "Publish only these boards (comma-separated); overrides the publish policy").option("--all-boards", "Publish every board - the loud override for the default-closed policy").option("--embed-seeds", "Copy comment history INTO the web root (identifying - every event carries its author's email)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
73
- const { buildSite } = await import("./build-ByYafIhj.mjs");
73
+ const { buildSite } = await import("./build-DfuTQZlY.mjs");
74
74
  try {
75
75
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
76
76
  await buildSite(resolve(opts.root), boards, opts.allBoards === true, opts.embedSeeds === true);
@@ -115,8 +115,8 @@ cli.command("work <action> [...frames]", "Working state on the canvas: start <sc
115
115
  process.exit(1);
116
116
  }
117
117
  });
118
- cli.command("shot <frame>", "Render one frame headless and print the PNG path (needs `dev` running)").option("--root <dir>", "Host repo root", { default: "." }).option("--theme <name>", "Theme to render (default: light)").action(async (frame, opts) => {
119
- const { shotCommand } = await import("./shot-DkkwuCZ2.mjs");
118
+ cli.command("shot <frame>", "Render one frame headless and print the PNG path (needs `dev` running)").option("--root <dir>", "Host repo root", { default: "." }).option("--theme <name>", "Theme to render (default: light)").option("--scale <n>", "Device pixels per CSS px, 1-4 (default: 2; 4 for print-quality stills)").action(async (frame, opts) => {
119
+ const { shotCommand } = await import("./shot-By1AItpD.mjs");
120
120
  try {
121
121
  await shotCommand(resolve(opts.root), frame, opts);
122
122
  } catch (err) {
@@ -1,7 +1,7 @@
1
1
  import { n as replay } from "./events-B3LBn74P.mjs";
2
2
  import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
3
3
  import { n as localProfile } from "./profile-BjAPAJSb.mjs";
4
- import { i as toFrameId } from "./manifest-DvOmglFp.mjs";
4
+ import { i as toFrameId } from "./manifest-BzxSMoDB.mjs";
5
5
  import { workActivity } from "./work-CLrmY-vQ.mjs";
6
6
  import { r as deviceId, t as has } from "./ledger-Bu0BjqIe.mjs";
7
7
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
@@ -1,6 +1,6 @@
1
1
  import { n as NAME, r as PKG } from "./cli.mjs";
2
- import { l as detectHost, s as loadConfig } from "./manifest-DvOmglFp.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BsmG5i2X.mjs";
2
+ import { l as detectHost, s as loadConfig } from "./manifest-BzxSMoDB.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DJyjmQeh.mjs";
4
4
  import { realpathSync } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -18,6 +18,14 @@ const portFree = (p) => new Promise((res) => {
18
18
  s.once("listening", () => s.close(() => res(true)));
19
19
  s.listen(p, "127.0.0.1");
20
20
  });
21
+ /** realpath, or the path itself when it cannot be resolved (never throws at boot). */
22
+ const safeReal = (p) => {
23
+ try {
24
+ return realpathSync(p);
25
+ } catch {
26
+ return p;
27
+ }
28
+ };
21
29
  /** C1: a deterministic per-project port in [5200,5399] derived from the root path. Two projects
22
30
  * never collide, and the same project always lands on the same port across restarts. */
23
31
  const projectPort = (root) => {
@@ -189,7 +197,9 @@ async function dev(root, portFlag) {
189
197
  fs: {
190
198
  allow: [.../* @__PURE__ */ new Set([
191
199
  root,
200
+ safeReal(root),
192
201
  pkgDir,
202
+ safeReal(pkgDir),
193
203
  searchForWorkspaceRoot(root)
194
204
  ])],
195
205
  deny: FS_DENY
@@ -247,7 +257,7 @@ async function dev(root, portFlag) {
247
257
  console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
248
258
  try {
249
259
  const { staleManagedInstructions } = await import("./managed-HwHVNI3h.mjs");
250
- const { installedVersion } = await import("./plugin-BsmG5i2X.mjs").then((n) => n.i);
260
+ const { installedVersion } = await import("./plugin-DJyjmQeh.mjs").then((n) => n.i);
251
261
  const stale = staleManagedInstructions(root);
252
262
  if (stale.length) {
253
263
  const shown = stale.slice(0, 4).join(", ") + (stale.length > 4 ? `, +${stale.length - 4} more` : "");
@@ -285,7 +295,7 @@ async function dev(root, portFlag) {
285
295
  });
286
296
  }
287
297
  if (config.jam) {
288
- const { startJam } = await import("./daemon-Bfucyf1o.mjs");
298
+ const { startJam } = await import("./daemon-DalgvoA9.mjs");
289
299
  const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
290
300
  if (jam) {
291
301
  const close = server.close.bind(server);
@@ -1,5 +1,5 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
- import { a as writeManifest, c as detectAgent, l as detectHost, o as DEFAULTS, r as scanFrames, u as readJson } from "./manifest-DvOmglFp.mjs";
2
+ import { a as writeManifest, c as detectAgent, l as detectHost, o as DEFAULTS, r as scanFrames, u as readJson } from "./manifest-BzxSMoDB.mjs";
3
3
  import { MANAGED_PREFIX, enumerateInstructionTemplates, hashBody, managedFile, pkgDir } from "./managed-HwHVNI3h.mjs";
4
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join, relative } from "node:path";
@@ -403,20 +403,31 @@ function extractMeta(src) {
403
403
  if (slide !== void 0) out.slide = slide;
404
404
  return out;
405
405
  }
406
- /** Content-frame detection: LEXICAL by convention - the frame file
407
- * itself imports PKG/content (an import specifier scan, so "<Diagram" inside a
408
- * string in a UI frame can never misbadge it; barrels/re-exports are not
409
- * detected - meta.intent is the taught path and always works). Returns the
410
- * inferred intent + natural width, or null for UI frames. */
406
+ /** Content-frame detection: LEXICAL by convention - the frame file itself imports
407
+ * PKG/content (an import specifier scan, so "<Diagram" inside a string in a UI frame can
408
+ * never misbadge it; barrels/re-exports are not detected - meta.intent is the taught path
409
+ * and always works) AND renders a DOCUMENT primitive: Doc, Md, Diagram or Img (reference
410
+ * imagery - a moodboard is Rows of Img). The import alone is not enough - Chart, Video, Row,
411
+ * Col and Space are shared blocks a UI screen or a slide uses too, and a dashboard with one
412
+ * <Chart> is still a screen (device height, no document measuring), never a spec. Returns
413
+ * the inferred intent + natural width, or null for UI frames. */
411
414
  const CONTENT_IMPORT = new RegExp(`from\\s+['"]${PKG}/content['"]`);
412
415
  const WIDE_DOC = /<Doc\b[^>]*\blayout\s*=\s*["']wide["']/;
413
416
  const contentWidthOf = (src) => WIDE_DOC.test(src) ? CONTENT_WIDTH.wide : CONTENT_WIDTH.document;
417
+ /** The source with comments, template literals and CODE-shaped strings blanked (a JSX
418
+ * expression string `{'<Diagram>'}`, an attribute value), so a tag name quoted in an example
419
+ * or a comment never counts as a rendered element. Prose is left alone: JSX text has
420
+ * apostrophes ("it's"), and a blanket quote match would swallow real tags between two of them. */
421
+ const codeOnly = (src) => src.replace(/`(?:[^`\\]|\\[\s\S])*`/g, "``").replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1").replace(/\{\s*(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')\s*\}/g, "{\"\"}").replace(/([=(,:[{?+|&!]|\breturn)\s*(?:"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')/g, "$1\"\"").replace(/([=(,:[{?+|&!]|\breturn)\s*\/(?:[^/\\\n[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+\/[gimsuy]*/g, "$1/re/");
414
422
  function contentScan(src) {
415
423
  if (!CONTENT_IMPORT.test(src)) return null;
416
- const count = (re) => (src.match(re) ?? []).length;
424
+ const code = codeOnly(src);
425
+ const count = (re) => (code.match(re) ?? []).length;
426
+ const docs = count(/<Doc[\s>/]/g);
417
427
  const diagrams = count(/<Diagram[\s>/]/g);
418
428
  const imgs = count(/<Img[\s>/]/g);
419
429
  const mds = count(/<Md[\s>/]/g);
430
+ if (docs + diagrams + mds + imgs === 0) return null;
420
431
  return {
421
432
  intent: diagrams > 0 ? "diagram" : imgs > mds ? "moodboard" : "spec",
422
433
  width: contentWidthOf(src)
@@ -1,7 +1,7 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
3
3
  import { n as localProfile, t as isConnected } from "./profile-BjAPAJSb.mjs";
4
- import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-DvOmglFp.mjs";
4
+ import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-BzxSMoDB.mjs";
5
5
  import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, join, resolve, sep } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -283,27 +283,59 @@ function apiMiddleware(root, opts = {}) {
283
283
  if (!ownerGated(req) && !(token && req.headers["x-mv-work"] === token)) return json(res, 403, { error: "forbidden" });
284
284
  const frameId = url.searchParams.get("frame") ?? "";
285
285
  const theme = url.searchParams.get("theme") ?? "light";
286
+ const scale = url.searchParams.has("scale") ? Number(url.searchParams.get("scale")) : void 0;
287
+ const dim = (k) => {
288
+ const v = Math.round(Number(url.searchParams.get(k)));
289
+ return url.searchParams.has(k) && Number.isFinite(v) ? v : void 0;
290
+ };
291
+ const size = {
292
+ w: dim("w"),
293
+ h: dim("h")
294
+ };
295
+ const asPng = url.searchParams.get("format") === "png";
286
296
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
287
- const { shootFrame } = await import("./shot-kbR_xzJH.mjs");
297
+ const { shootFrame } = await import("./shot-BWhoz6cU.mjs");
288
298
  const r = await shootFrame({
289
299
  root,
290
300
  viewports: opts.viewports ?? {},
291
301
  frameId,
292
302
  theme,
293
- origin
303
+ origin,
304
+ scale,
305
+ size
294
306
  });
295
- if (!r.ok) return json(res, r.error.startsWith("unknown frame") ? 404 : r.error === "invalid theme" ? 400 : 503, { error: r.error });
296
- return json(res, 200, {
307
+ if (!r.ok) return json(res, r.error.startsWith("unknown frame") ? 404 : r.error.startsWith("invalid ") ? 400 : 503, { error: r.error });
308
+ const summary = {
297
309
  path: r.path,
298
310
  frame: frameId,
299
311
  theme,
300
312
  width: r.width,
301
313
  height: r.height,
302
314
  scale: r.scale,
303
- ...r.truncated ? {
304
- truncated: true,
305
- note: r.note
306
- } : {}
315
+ ...r.truncated ? { truncated: true } : {},
316
+ ...r.note ? { note: r.note } : {}
317
+ };
318
+ if (!asPng) return json(res, 200, summary);
319
+ const png = readFileSync(join(root, r.path));
320
+ res.writeHead(200, {
321
+ "content-type": "image/png",
322
+ "content-length": png.length,
323
+ "cache-control": "no-store",
324
+ "x-mv-shot": Buffer.from(JSON.stringify(summary)).toString("base64url")
325
+ });
326
+ return res.end(png);
327
+ }
328
+ if (path === "poster" && req.method === "GET") {
329
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
330
+ const src = url.searchParams.get("src") ?? "";
331
+ const { ensurePoster, isLocalClip } = await import("./poster-CbpzSzJu.mjs");
332
+ const { isLocalAssetRef } = await import("./build-DfuTQZlY.mjs");
333
+ if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
334
+ const r = await ensurePoster(join(root, "design", "assets"), src);
335
+ if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
336
+ return json(res, 200, {
337
+ path: `design/assets/${src}.poster.png`,
338
+ generated: r.generated
307
339
  });
308
340
  }
309
341
  if (path === "me" && req.method === "GET") return json(res, 200, {
@@ -817,7 +849,7 @@ function marverPlugin(ctx) {
817
849
  });
818
850
  return;
819
851
  }
820
- const { shootFrame } = await import("./shot-kbR_xzJH.mjs");
852
+ const { shootFrame } = await import("./shot-BWhoz6cU.mjs");
821
853
  write(await shootFrame({
822
854
  root,
823
855
  viewports: config.viewports,
@@ -0,0 +1,143 @@
1
+ import { capture, findChrome } from "./shot-BWhoz6cU.mjs";
2
+ import { constants, copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, sep } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { randomBytes } from "node:crypto";
6
+ import { tmpdir } from "node:os";
7
+ //#region src/server/poster.ts
8
+ /**
9
+ * Video posters, generated: a local clip without an authored poster gets one rendered
10
+ * from its own first moments - the same headless Chrome the shot renderer drives, no
11
+ * ffmpeg. The convention is `<clip>.poster.png` beside the clip in design/assets/, and
12
+ * the Video primitive reaches for that name whenever `poster` is omitted, so the dev
13
+ * server (on demand, when the frame first asks) and `marver build` (before assets are
14
+ * copied - a published canvas never ships a poster-less video) produce one file both
15
+ * agree on. An authored poster always wins: this only runs when the file is missing.
16
+ *
17
+ * The frame is taken at min(0.5s, half the duration) - the first frame of most clips is
18
+ * black or a fade. The page is a file:// document holding the clip as a file:// <video>,
19
+ * so nothing has to be served; readiness is the video element appearing in #root once
20
+ * the seek lands, which is the shot renderer's own readiness rule.
21
+ */
22
+ /** The conventional poster path for a local clip reference (relative to design/assets/). */
23
+ const posterNameFor = (src) => `${src}.poster.png`;
24
+ /** Is this a video reference the generator handles (a local design-asset clip)? The same
25
+ * grammar the client's assetUrl accepts: relative, forward slashes only, no dot segments. */
26
+ const isLocalClip = (src) => !/^https?:\/\//.test(src) && /\.(mp4|webm|mov|m4v|ogv)$/i.test(src) && !src.includes("\\") && !src.includes(":") && !src.startsWith("/") && !src.split("/").some((seg) => seg === ".." || seg === "." || seg === "");
27
+ /** realpath(p) is base or inside it. */
28
+ const inside = (p, base) => {
29
+ try {
30
+ const r = realpathSync(p);
31
+ return r === base || r.startsWith(base + sep);
32
+ } catch {
33
+ return false;
34
+ }
35
+ };
36
+ const PAGE = (video) => `<!doctype html><html><head><meta charset="utf-8"><style>
37
+ html,body{margin:0;background:#000}#root video{display:block}
38
+ </style></head><body><div id="root"></div><script>
39
+ (function(){
40
+ var v = document.createElement('video');
41
+ v.muted = true; v.playsInline = true; v.preload = 'auto'; v.src = ${JSON.stringify(video)};
42
+ var done = false;
43
+ function show(){ if (done) return; done = true;
44
+ var w = Math.min(1920, v.videoWidth || 1280), h = Math.round(w * ((v.videoHeight || 720) / (v.videoWidth || 1280)));
45
+ v.width = w; v.height = h; v.style.width = w + 'px'; v.style.height = h + 'px';
46
+ document.getElementById('root').appendChild(v) }
47
+ v.addEventListener('loadedmetadata', function(){
48
+ var t = Math.min(0.5, (isFinite(v.duration) ? v.duration : 1) / 2);
49
+ v.addEventListener('seeked', show, { once: true });
50
+ v.currentTime = t; });
51
+ v.addEventListener('error', function(){ window.__mvFrameError = 'the clip could not be decoded by this browser (' + (v.error && v.error.code) + ')' ; document.getElementById('root').appendChild(document.createElement('i')) });
52
+ setTimeout(function(){ if (!done && v.readyState >= 2) show() }, 4000);
53
+ })();
54
+ <\/script></body></html>`;
55
+ /** Render `<clip>.poster.png` for a clip under design/assets/. Idempotent: an existing
56
+ * poster (authored or generated) is left alone. */
57
+ async function ensurePoster(assetsDir, src) {
58
+ if (!isLocalClip(src)) return {
59
+ ok: false,
60
+ error: `not a local clip: ${src}`
61
+ };
62
+ const clip = join(assetsDir, src);
63
+ const out = join(assetsDir, posterNameFor(src));
64
+ if (existsSync(out)) return {
65
+ ok: true,
66
+ path: out,
67
+ width: 0,
68
+ height: 0,
69
+ generated: false
70
+ };
71
+ if (!existsSync(clip)) return {
72
+ ok: false,
73
+ error: `design/assets/${src} does not exist`
74
+ };
75
+ let realAssets;
76
+ try {
77
+ realAssets = realpathSync(assetsDir);
78
+ } catch {
79
+ return {
80
+ ok: false,
81
+ error: "design/assets/ does not exist"
82
+ };
83
+ }
84
+ if (!inside(clip, realAssets)) return {
85
+ ok: false,
86
+ error: `design/assets/${src} resolves outside design/assets/ - not rendered`
87
+ };
88
+ if (!inside(dirname(out), realAssets)) return {
89
+ ok: false,
90
+ error: `design/assets/${dirname(src)} resolves outside design/assets/ - not rendered`
91
+ };
92
+ if (!findChrome()) return {
93
+ ok: false,
94
+ error: `no Chrome/Chromium found to render a poster for ${src} - add poster="..." on the <Video>, or install Chrome`
95
+ };
96
+ const dir = mkdtempSync(join(tmpdir(), "mv-poster-"));
97
+ const page = join(dir, "poster.html");
98
+ writeFileSync(page, PAGE(pathToFileURL(realpathSync(clip)).href));
99
+ const tmp = join(dirname(out), `.${basename(out)}.${randomBytes(6).toString("hex")}.tmp`);
100
+ try {
101
+ const r = await capture({
102
+ url: pathToFileURL(page).href,
103
+ width: 1920,
104
+ height: 1080,
105
+ scale: 1,
106
+ out: tmp,
107
+ fullHeight: false,
108
+ clip: "#root video",
109
+ timeoutMs: 2e4
110
+ }, "poster");
111
+ if (!r.ok) return {
112
+ ok: false,
113
+ error: `could not render a poster for ${basename(src)} - ${r.error}`
114
+ };
115
+ try {
116
+ copyFileSync(tmp, out, constants.COPYFILE_EXCL);
117
+ } catch (err) {
118
+ if (err.code === "EEXIST") return {
119
+ ok: true,
120
+ path: out,
121
+ width: r.width,
122
+ height: r.height,
123
+ generated: false
124
+ };
125
+ throw err;
126
+ }
127
+ return {
128
+ ok: true,
129
+ path: out,
130
+ width: r.width,
131
+ height: r.height,
132
+ generated: true
133
+ };
134
+ } finally {
135
+ rmSync(tmp, { force: true });
136
+ rmSync(dir, {
137
+ recursive: true,
138
+ force: true
139
+ });
140
+ }
141
+ }
142
+ //#endregion
143
+ export { ensurePoster, isLocalClip };