@marver-design/marver 0.16.0 → 0.17.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 (33) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +4 -4
  3. package/dist/{boards-BdG1TJwU.mjs → boards-BmxcT3Lc.mjs} +32 -9
  4. package/dist/{boards-6hKVW42a.mjs → boards-PuVzw5Wp.mjs} +9 -4
  5. package/dist/{build-Ct0iMoSi.mjs → build-D_g53Bp2.mjs} +12 -8
  6. package/dist/cli.mjs +27 -16
  7. package/dist/{daemon-Cb4JjpgL.mjs → daemon-Bbh_jmui.mjs} +1 -1
  8. package/dist/{dev-CdcIuhZJ.mjs → dev-LnIISva5.mjs} +9 -4
  9. package/dist/{init-BQIpIpIv.mjs → init-B7YhcN2o.mjs} +1 -1
  10. package/dist/{manifest-CpbsqQ_v.mjs → manifest-CaslQIAO.mjs} +112 -15
  11. package/dist/{plugin-BXyezwfN.mjs → plugin-D2msH1cj.mjs} +223 -43
  12. package/dist/{poster-DOY7pax8.mjs → poster-BEjUcQP3.mjs} +13 -5
  13. package/dist/shot-BzQ0PXKH.mjs +86 -0
  14. package/dist/shot-DlmTO8AF.mjs +926 -0
  15. package/docs/live-jam.md +20 -8
  16. package/docs/publish.md +2 -2
  17. package/package.json +1 -1
  18. package/src/client/shell/App.tsx +42 -14
  19. package/src/client/shell/BoardList.tsx +88 -58
  20. package/src/client/shell/LockedApp.tsx +2 -1
  21. package/src/client/shell/Play.tsx +1 -0
  22. package/src/client/shell/labels.ts +4 -4
  23. package/src/client/shell/store.ts +76 -29
  24. package/src/client/shell/styles.css +5 -1
  25. package/src/shared/board-tree.ts +104 -46
  26. package/templates/AGENTS-embedded.md +13 -3
  27. package/templates/AGENTS-studio.md +13 -3
  28. package/templates/instructions/boards.md +27 -9
  29. package/templates/instructions/discover.md +13 -1
  30. package/templates/instructions/jam.md +15 -3
  31. package/templates/instructions/shape.md +2 -1
  32. package/dist/shot-By1AItpD.mjs +0 -30
  33. package/dist/shot-CwmHO5T4.mjs +0 -528
package/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
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.17.0 - 2026-09-04
6
+
7
+ ### Added
8
+
9
+ - **Shots in batch.** `npx marver shot a b c`, `--scene <name>` or `--all` render many
10
+ frames as ONE operation: one headless browser, several frames at a time inside it
11
+ (`MARVER_SHOT_CONCURRENCY`, default up to 6 sized to the machine). `POST /api/shots`
12
+ and the jam inbox (`{"scene":...}`, `{"frames":[...]}`, `{"all":true}`) take the same
13
+ ask; every frame answers for itself (`results[]`, in the order asked). Measured on a
14
+ 31-frame canvas: 65 s of one-at-a-time calls becomes 8 s; a 5-frame scene, 10 s
15
+ becomes 2 s; a single shot, 2.1 s becomes 1.05 s.
16
+ - A capture that ran out of settle budget says so: `unsettled: true` and a note, instead
17
+ of a silently half-drawn PNG.
18
+
19
+ ### Fixed
20
+
21
+ - **A shot's Chrome could outlive the server** - Ctrl-C, a closed terminal or `kill -9`
22
+ mid-shot left a headless copy of the user's own Chrome running for ever, and on macOS
23
+ such a ghost can swallow every link the machine opens. The browser is now driven over
24
+ Chrome's debugging pipe, so it dies with the server however the server dies, and none
25
+ exists between shots. The first `marver dev` after upgrading kills the ghosts and
26
+ removes the profiles earlier versions left in the temp dir.
27
+ - Chrome starts and opens tabs several times faster: a stale `--disable-gpu` flag was
28
+ costing ~0.5 s per start and per tab.
29
+
30
+ ## 0.16.1 - 2026-09-03
31
+
32
+ ### Added
33
+
34
+ - **Titles.** Boards, folders and scenes can be called anything - "MVP", "UI",
35
+ "Checkout (v2) 🛒". Rename in the sidebar (scenes too, now) edits a `title`;
36
+ the file name, folder key and scene directory stay the slug agents address.
37
+ No title = the Title-Cased slug, as before. Stored on the board JSON, the
38
+ `_folders.json` entry and the brief's YAML front matter; shown everywhere the
39
+ object is named and carried by the manifest and `npx marver boards`.
40
+
41
+ ### Fixed
42
+
43
+ - **Drops land where the seam shows.** A board grabbed by its icon and dragged
44
+ between two folder boards landed after the folder; releases in blank space did
45
+ nothing. The resolver now reads the pointer against the rendered rows, the
46
+ release applies the seam on screen, and a release outside the panel cancels.
47
+ - A project reached through a symlink (macOS `/var`, a linked projects folder)
48
+ lost its live updates; the CLI now resolves the root's real path.
49
+
50
+ ### Changed
51
+
52
+ - `boards/rename` takes `{ from, to?, title?, baseHash? }` (title-only, CAS);
53
+ new `scenes/rename { scene, title }`; `sh:scenes` relabels scenes without
54
+ re-keying iframes.
55
+
5
56
  ## 0.16.0 - 2026-09-03
6
57
 
7
58
  ### Added
package/README.md CHANGED
@@ -40,7 +40,7 @@ Frames appear on the canvas the moment the files land. That's the loop.
40
40
 
41
41
  ## The canvas
42
42
 
43
- - **Frames, scenes, boards.** Frames are screens, scenes group them (`design/scenes/<scene>/<frame>.tsx`), boards arrange them. Agents write `design/boards/<name>.json` (a frame list is enough); switch boards at the top of the sidebar. `all-scenes` is auto-managed. Right-click any board, scene, or frame in the sidebar to copy its path - the exact string to paste to your agent - and rename or drag-reorder boards from there too. Boards can live in folders: right-click the Boards header (or its `+`) for a new one, name it inline, drag boards in and out and folders among boards; agents do the same by writing `"folder": "<name>"` on a board and `design/boards/_folders.json` for empty or ranked folders (`npx marver boards` prints the tree). Every object takes a one-sentence `description` (project in `design/config.ts`, boards and folders in their JSON, a scene's first `_brief.md` line, `meta.description` on a frame) and `design/manifest.json` carries them all - a new agent session orients in one read.
43
+ - **Frames, scenes, boards.** Frames are screens, scenes group them (`design/scenes/<scene>/<frame>.tsx`), boards arrange them. Agents write `design/boards/<name>.json` (a frame list is enough); switch boards at the top of the sidebar. `all-scenes` is auto-managed. Right-click any board, scene, or frame in the sidebar to copy its path - the exact string to paste to your agent - and rename or drag-reorder boards from there too. Boards can live in folders: right-click the Boards header (or its `+`) for a new one, name it inline, drag boards in and out (the seam shows exactly where a release lands) and folders among boards; agents do the same by writing `"folder": "<name>"` on a board and `design/boards/_folders.json` for empty or ranked folders (`npx marver boards` prints the tree). Boards, folders and scenes take a `title` - any name you like, "MVP", "UI", "Checkout (v2) 🛒" - while their file, key and directory stay the slug agents address; Rename in the sidebar edits the title. Every object takes a one-sentence `description` (project in `design/config.ts`, boards and folders in their JSON, a scene's first `_brief.md` line, `meta.description` on a frame) and `design/manifest.json` carries them all - a new agent session orients in one read.
44
44
  - **Devices view.** Hotkeys `1`-`5` (or the Devices menu) size every frame to mobile / tablet / laptop / monitor / tv to sweep your breakpoints; `0` restores your own layout exactly. Widths live in `design/config.ts`.
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.
@@ -84,7 +84,7 @@ The trust boundary is hard: only comments written on the owner's machine trigger
84
84
 
85
85
  ## Working state
86
86
 
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.
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 <scene>` (or `<scene/frame ...>`, `--all`; `[--scale 4]`) renders frames headless to PNGs - a whole scene in one browser, several frames at a time - 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.
88
88
 
89
89
  ## Commands
90
90
 
@@ -97,8 +97,8 @@ The same glow, driven from the terminal. When your agent takes a request, it cre
97
97
  | `npx marver share …` | The roster (owner): `add <who> [--role]` · `remove` · `block` / `unblock` · `general <mode>` · `list` · `requests` · `explain <who>` · `who` |
98
98
  | `npx marver comments …` | The agent's queue: `connect <url>` · `sync` · `list` · `reply` · `resolve` · `invite <email>` · `revoke <email>` |
99
99
  | `npx marver work …` | Working glow from the terminal: `start <scene/frame …>` · `done … \| --all` · `list` |
100
- | `npx marver shot <frame> [--scale 1-4]` | Render one frame headless and print the PNG path (needs `dev` running); 2x by default |
101
- | `npx marver boards [--json]` | The sidebar as the files say it is: folders, boards in reading order with `order` and description, the landing board |
100
+ | `npx marver shot <frame ...> \| --scene <name> \| --all [--scale 1-4] [--json]` | Render frames headless and print the PNG paths (needs `dev` running); a scene is one browser, several frames at a time; 2x by default |
101
+ | `npx marver boards [--json]` | The sidebar as the files say it is: folders, boards in reading order with their title, `order` and description, the landing board |
102
102
 
103
103
  ## Shortcuts
104
104
 
@@ -25,7 +25,16 @@ const readDescription = (v) => {
25
25
  if (typeof v !== "string") return void 0;
26
26
  return v.trim().replace(/\s+/g, " ").slice(0, 300) || void 0;
27
27
  };
28
+ const readTitle = (v) => {
29
+ if (typeof v !== "string") return void 0;
30
+ return Array.from(v.replace(/[\u0000-\u001f\u007f]/g, " ").trim().replace(/\s+/g, " ")).slice(0, 120).join("").trim() || void 0;
31
+ };
28
32
  const rank = (o) => typeof o === "number" && Number.isFinite(o) ? o : Infinity;
33
+ /** A folder's title and description, present only when set. */
34
+ const folderExtras = (it) => ({
35
+ ...it.title ? { title: it.title } : {},
36
+ ...it.description ? { description: it.description } : {}
37
+ });
29
38
  /** The registry file's shape. Returns the rows, or a string naming what is wrong - a
30
39
  * malformed registry is an ERROR the human must fix (silently reading it as empty would
31
40
  * let the next drag overwrite their folders), while a missing file is simply no folders. */
@@ -42,10 +51,12 @@ function parseFolders(raw) {
42
51
  if (seen.has(name)) return `folder "${name}" is listed twice`;
43
52
  seen.add(name);
44
53
  const o = f.order;
54
+ const t = readTitle(f.title);
45
55
  const d = readDescription(f.description);
46
56
  out.push({
47
57
  name,
48
58
  ...typeof o === "number" && Number.isFinite(o) ? { order: o } : {},
59
+ ...t ? { title: t } : {},
49
60
  ...d ? { description: d } : {}
50
61
  });
51
62
  }
@@ -53,13 +64,15 @@ function parseFolders(raw) {
53
64
  }
54
65
  /** Sidebar order from the files. Root: boards with no folder + every folder (registered
55
66
  * or implied by a board), ranked by `order` then kind (board before folder) then name.
56
- * Inside a folder: its boards by `order` then name. Unranked sorts after ranked. */
67
+ * Inside a folder: its boards by `order` then name. Unranked sorts after ranked. A folder's
68
+ * title and description ride on its item (they live in the registry a tree write rewrites);
69
+ * a board's title stays with its row - it lives in the board's own file. */
57
70
  function buildTree(boards, folders) {
58
71
  const folderOrder = /* @__PURE__ */ new Map();
59
- const folderDesc = /* @__PURE__ */ new Map();
72
+ const folderMeta = /* @__PURE__ */ new Map();
60
73
  for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) {
61
74
  folderOrder.set(f.name, f.order);
62
- if (f.description) folderDesc.set(f.name, f.description);
75
+ folderMeta.set(f.name, folderExtras(f));
63
76
  }
64
77
  const members = /* @__PURE__ */ new Map();
65
78
  const rootBoards = [];
@@ -87,7 +100,7 @@ function buildTree(boards, folders) {
87
100
  kind: "folder",
88
101
  name,
89
102
  boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name),
90
- ...folderDesc.has(name) ? { description: folderDesc.get(name) } : {}
103
+ ...folderMeta.get(name) ?? {}
91
104
  },
92
105
  order
93
106
  }))];
@@ -117,8 +130,9 @@ function validateWire(wire) {
117
130
  continue;
118
131
  }
119
132
  if (!w || typeof w !== "object" || Array.isArray(w)) return "invalid tree item";
120
- const { folder, boards: kids, description } = w;
133
+ const { folder, boards: kids, title, description } = w;
121
134
  if (!isBoardName(folder)) return "invalid folder name in tree";
135
+ if (title !== void 0 && (typeof title !== "string" || Array.from(title).length > 120)) return "invalid folder title";
122
136
  if (description !== void 0 && (typeof description !== "string" || description.length > 300)) return "invalid folder description";
123
137
  if (folders.has(folder)) return `folder "${folder}" appears twice`;
124
138
  folders.add(folder);
@@ -155,8 +169,14 @@ function underRoot(root, dir) {
155
169
  * it; a link outside would publish foreign JSON) and must resolve inside the root. Absent
156
170
  * is fine (no boards yet). Returns the error, or null. */
157
171
  function checkBoardsDir(root, boardsDir) {
158
- const design = join(boardsDir, "..");
159
- for (const [p, label] of [[design, "design"], [boardsDir, "design/boards"]]) {
172
+ return checkRealDirs(root, [[join(boardsDir, ".."), "design"], [boardsDir, "design/boards"]]);
173
+ }
174
+ /** Every EXISTING path in `dirs` (root-down order) must be a real directory inside the root -
175
+ * a symlinked `design` with no boards dir yet would otherwise be followed by the mkdir that
176
+ * creates it; a symlinked `design/scenes` would let a brief write land outside the project.
177
+ * An absent one ends the walk (nothing beneath it exists either). Returns the error, or null. */
178
+ function checkRealDirs(root, dirs) {
179
+ for (const [p, label] of dirs) {
160
180
  try {
161
181
  if (lstatSync(p).isSymbolicLink()) return `${label} must be a real directory, not a symlink`;
162
182
  } catch {
@@ -216,13 +236,16 @@ function listBoardFiles(boardsDir) {
216
236
  skipped
217
237
  };
218
238
  }
219
- /** The author-owned sidebar fields off a board's JSON, leniently. */
239
+ /** The author-owned sidebar fields off a board's JSON, leniently: rank, folder, the title
240
+ * humans see, the sentence agents read. */
220
241
  function boardFields(json, validName) {
221
242
  const o = json;
243
+ const title = readTitle(o?.title);
222
244
  const description = readDescription(o?.description);
223
245
  return {
224
246
  ...typeof o?.order === "number" && Number.isFinite(o.order) ? { order: o.order } : {},
225
247
  ...validName(o?.folder) ? { folder: o.folder } : {},
248
+ ...title ? { title } : {},
226
249
  ...description ? { description } : {}
227
250
  };
228
251
  }
@@ -264,4 +287,4 @@ function readRegistry(boardsDir) {
264
287
  };
265
288
  }
266
289
  //#endregion
267
- export { nodeExists as a, FOLDERS_FILE as c, isBoardName as d, readDescription as f, listBoardFiles as i, buildTree as l, hash as m, checkBoardsDir as n, readRegistry as o, validateWire as p, isRegularFile as r, BOARD_NAME as s, boardFields as t, flatten as u };
290
+ export { listBoardFiles as a, BOARD_NAME as c, flatten as d, isBoardName as f, hash as g, validateWire as h, isRegularFile as i, FOLDERS_FILE as l, readTitle as m, checkBoardsDir as n, nodeExists as o, readDescription as p, checkRealDirs as r, readRegistry as s, boardFields as t, buildTree as u };
@@ -1,4 +1,4 @@
1
- import { d as isBoardName, i as listBoardFiles, l as buildTree, n as checkBoardsDir, o as readRegistry, t as boardFields, u as flatten } from "./boards-BdG1TJwU.mjs";
1
+ import { a as listBoardFiles, d as flatten, f as isBoardName, n as checkBoardsDir, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BmxcT3Lc.mjs";
2
2
  import { join } from "node:path";
3
3
  //#region src/cli/boards.ts
4
4
  /**
@@ -37,14 +37,19 @@ function boardsCommand(root, opts) {
37
37
  const o = rows.find((r) => r.name === n)?.order;
38
38
  return o === void 0 ? "" : ` order ${o}`;
39
39
  };
40
+ const title = (t) => t ? ` "${t}"` : "";
40
41
  const desc = (d) => d ? ` - ${d}` : "";
42
+ const boardLine = (n) => {
43
+ const r = rows.find((x) => x.name === n);
44
+ return `${n}${title(r?.title)}${order(n)}${desc(r?.description)}`;
45
+ };
41
46
  for (const it of tree) {
42
47
  if (it.kind === "board") {
43
- console.log(`${it.name}${order(it.name)}${desc(rows.find((r) => r.name === it.name)?.description)}`);
48
+ console.log(boardLine(it.name));
44
49
  continue;
45
50
  }
46
- console.log(`${it.name}/ (folder, ${it.boards.length} board${it.boards.length === 1 ? "" : "s"}${reg.folders.some((f) => f.name === it.name) ? "" : ", implied by its boards - not in _folders.json"})${desc(it.description)}`);
47
- for (const b of it.boards) console.log(` ${b}${order(b)}${desc(rows.find((r) => r.name === b)?.description)}`);
51
+ console.log(`${it.name}/${title(it.title)} (folder, ${it.boards.length} board${it.boards.length === 1 ? "" : "s"}${reg.folders.some((f) => f.name === it.name) ? "" : ", implied by its boards - not in _folders.json"})${desc(it.description)}`);
52
+ for (const b of it.boards) console.log(` ${boardLine(b)}`);
48
53
  if (!it.boards.length) console.log(" (empty)");
49
54
  }
50
55
  if (hasAll) console.log("all-scenes (auto, always last)");
@@ -1,7 +1,7 @@
1
1
  import { i as ROUTE, n as NAME } from "./cli.mjs";
2
- import { i as listBoardFiles, l as buildTree, n as checkBoardsDir, o as readRegistry, u as flatten } from "./boards-BdG1TJwU.mjs";
3
- import { c as detectHost, n as scanFrames, o as loadConfig } from "./manifest-CpbsqQ_v.mjs";
4
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BXyezwfN.mjs";
2
+ import { a as listBoardFiles, d as flatten, f as isBoardName, n as checkBoardsDir, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BmxcT3Lc.mjs";
3
+ import { l as detectHost, n as scanFrames, s as loadConfig } from "./manifest-CaslQIAO.mjs";
4
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-D2msH1cj.mjs";
5
5
  import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, join, sep } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -171,11 +171,9 @@ function readBoards(root) {
171
171
  * the landing). `names[0]` is where `/` opens. Folder names of published boards are structure,
172
172
  * like board names: they ship. */
173
173
  function publishedTree(published, allBoards, folders) {
174
- const field = (n, k) => allBoards[n]?.[k];
175
174
  const tree = buildTree(published.filter((n) => n !== "all-scenes").map((n) => ({
176
175
  name: n,
177
- order: field(n, "order"),
178
- folder: field(n, "folder")
176
+ ...boardFields(allBoards[n], isBoardName)
179
177
  })), folders).filter((it) => it.kind === "board" || it.boards.length > 0);
180
178
  return {
181
179
  tree,
@@ -195,9 +193,10 @@ function publishedManifest(manifest, pubFrames, publishedNames, strip) {
195
193
  ...manifest.project ? { project: manifest.project } : {},
196
194
  ...pubFolders.length ? { folders: pubFolders } : {},
197
195
  ...pubBoards.length ? { boards: pubBoards } : {},
198
- scenes: manifest.scenes.filter((s) => pubScenes.has(s.name)).map(({ name, description, brief }) => ({
196
+ scenes: manifest.scenes.filter((s) => pubScenes.has(s.name)).map(({ name, title, description, brief }) => ({
199
197
  name,
200
198
  frames: pubFrames.filter((f) => f.scene === name).length,
199
+ ...title ? { title } : {},
201
200
  ...description ? { description } : {},
202
201
  ...brief && !strip ? { brief } : {}
203
202
  })),
@@ -364,11 +363,16 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
364
363
  "slides"
365
364
  ].includes(p.open);
366
365
  });
366
+ const titles = Object.fromEntries(publishedNames.flatMap((n) => {
367
+ const t = boardFields(allBoards[n], isBoardName).title;
368
+ return t ? [[n, t]] : [];
369
+ }));
367
370
  const data = {
368
371
  manifest: pubManifest,
369
372
  boards,
370
373
  names: publishedNames,
371
374
  tree,
375
+ ...Object.keys(titles).length ? { titles } : {},
372
376
  default: publishedNames.find((n) => n !== "all-scenes") ?? publishedNames[0],
373
377
  rights,
374
378
  policy: {
@@ -500,7 +504,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
500
504
  const realAssets = existsSync(assetsDir) ? realpathSync(assetsDir) : null;
501
505
  for (const r of refs) {
502
506
  if (!isLocalAssetRef(r) || !r.endsWith(".poster.png") || existsSync(join(assetsDir, r))) continue;
503
- const { ensurePoster } = await import("./poster-DOY7pax8.mjs");
507
+ const { ensurePoster } = await import("./poster-BEjUcQP3.mjs");
504
508
  const g = await ensurePoster(assetsDir, r.slice(0, -11));
505
509
  if (!g.ok) throw new Error(`design/assets/${r}: ${g.error}`);
506
510
  console.log(` poster: rendered design/assets/${r} (${g.width}×${g.height})`);
package/dist/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { cac } from "cac";
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, realpathSync } from "node:fs";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  //#region src/client/const.ts
@@ -51,29 +51,40 @@ function version() {
51
51
  return "0.0.0";
52
52
  }
53
53
  }
54
+ /** The project root, REAL path. A root reached through a symlink (macOS's /var → /private/var,
55
+ * a linked projects folder) would be watched under the link while the OS reports events under
56
+ * the real path - and every frame add, brief edit and config change would go unseen. */
57
+ const resolve$1 = (dir) => {
58
+ const p = resolve(dir);
59
+ try {
60
+ return realpathSync(p);
61
+ } catch {
62
+ return p;
63
+ }
64
+ };
54
65
  const cli = cac(NAME);
55
66
  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-BQIpIpIv.mjs");
57
- init(resolve(opts.root), {
67
+ const { init } = await import("./init-B7YhcN2o.mjs");
68
+ init(resolve$1(opts.root), {
58
69
  mode: opts.mode === "embedded" ? "embedded" : "studio",
59
70
  demo: opts.demo !== false
60
71
  });
61
72
  });
62
73
  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-CdcIuhZJ.mjs");
74
+ const { dev } = await import("./dev-LnIISva5.mjs");
64
75
  let port;
65
76
  if (opts.port !== void 0) {
66
77
  const n = Number(opts.port);
67
78
  if (Number.isInteger(n) && n > 0 && n < 65536) port = n;
68
79
  else console.warn(`[${NAME}] ignoring invalid --port "${opts.port}"`);
69
80
  }
70
- await dev(resolve(opts.root), port);
81
+ await dev(resolve$1(opts.root), port);
71
82
  });
72
83
  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-Ct0iMoSi.mjs");
84
+ const { buildSite } = await import("./build-D_g53Bp2.mjs");
74
85
  try {
75
86
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
76
- await buildSite(resolve(opts.root), boards, opts.allBoards === true, opts.embedSeeds === true);
87
+ await buildSite(resolve$1(opts.root), boards, opts.allBoards === true, opts.embedSeeds === true);
77
88
  } catch (err) {
78
89
  console.error(`[${NAME}] build failed: ${err.message}`);
79
90
  process.exit(1);
@@ -86,12 +97,12 @@ cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").opti
86
97
  const n = Number(opts.port);
87
98
  if (Number.isInteger(n) && n > 0 && n < 65536) port = n;
88
99
  }
89
- serve(resolve(opts.root), port);
100
+ serve(resolve$1(opts.root), port);
90
101
  });
91
102
  cli.command("comments <action> [value]", "Comment collaboration: connect <url> · sync · list · reply <thread> · resolve <thread> · invite <email> · revoke <email>").option("--root <dir>", "Host repo root", { default: "." }).option("--token <token>", "connect: the canvas's MARVER_CLI_TOKEN (default $MARVER_CLI_TOKEN) - the identity-mode path").option("--invite <token>", "connect: claim this invite instead of signing in").option("--canvas-password <password>", "connect: the canvas gate password (default $MARVER_PASSWORD or prompt)").option("--email <email>", "connect: account email (skips the prompt)").option("--password <password>", "connect: account password (skips the prompt - mind your shell history)").option("--name <name>", "connect --invite: display name for the new account").option("--open", "list: only unresolved threads").option("--json", "list: machine-readable output").option("--board <board>", "scope to one board").option("--body <text>", "reply: the reply text").option("--addressed-in <frame>", "resolve: the variant frame that answered the feedback").action(async (action, value, opts) => {
92
103
  const { commentsCommand } = await import("./comments-oYcZ3cE-.mjs");
93
104
  try {
94
- await commentsCommand(resolve(opts.root), action, value, opts);
105
+ await commentsCommand(resolve$1(opts.root), action, value, opts);
95
106
  } catch (err) {
96
107
  console.error(`[${NAME}] ${err.message}`);
97
108
  process.exit(1);
@@ -100,7 +111,7 @@ cli.command("comments <action> [value]", "Comment collaboration: connect <url>
100
111
  cli.command("share <action> [value]", "Sharing roster (owner): add <who> · remove <who> · block/unblock <email> · general <mode> · list · requests · explain <who> · who").option("--root <dir>", "Host repo root", { default: "." }).option("--role <role>", "add / requests --approve: view (default) or comment").option("--expires <iso>", "add: expiry timestamp, e.g. 2026-12-31T00:00:00Z").option("--approve <email>", "requests: approve this pending request (canvas-wide in v1)").option("--decline <email>", "requests: decline this pending request (silent to the asker)").option("--json", "list: machine-readable output").action(async (action, value, opts) => {
101
112
  const { shareCommand } = await import("./share-Gqo_Ygqw.mjs");
102
113
  try {
103
- await shareCommand(resolve(opts.root), action, value, opts);
114
+ await shareCommand(resolve$1(opts.root), action, value, opts);
104
115
  } catch (err) {
105
116
  console.error(`[${NAME}] ${err.message}`);
106
117
  process.exit(1);
@@ -109,25 +120,25 @@ cli.command("share <action> [value]", "Sharing roster (owner): add <who> · remo
109
120
  cli.command("work <action> [...frames]", "Working state on the canvas: start <scene/frame ...> · done <scene/frame ...> | --all · list").option("--root <dir>", "Host repo root", { default: "." }).option("--ttl <minutes>", "start: minutes before the glow self-expires (default 10, max 30)").option("--all", "done: clear every working frame").action(async (action, frames, opts) => {
110
121
  const { workCommand } = await import("./work-lzC-lPY0.mjs");
111
122
  try {
112
- await workCommand(resolve(opts.root), action, frames ?? [], opts);
123
+ await workCommand(resolve$1(opts.root), action, frames ?? [], opts);
113
124
  } catch (err) {
114
125
  console.error(`[${NAME}] ${err.message}`);
115
126
  process.exit(1);
116
127
  }
117
128
  });
118
129
  cli.command("boards", "The sidebar as files: every folder and board in reading order, the landing board, the registry").option("--root <dir>", "Host repo root", { default: "." }).option("--json", "The tree as JSON ({ tree, landing, registry })").action(async (opts) => {
119
- const { boardsCommand } = await import("./boards-6hKVW42a.mjs");
130
+ const { boardsCommand } = await import("./boards-PuVzw5Wp.mjs");
120
131
  try {
121
- boardsCommand(resolve(opts.root), opts);
132
+ boardsCommand(resolve$1(opts.root), opts);
122
133
  } catch (err) {
123
134
  console.error(`[${NAME}] ${err.message}`);
124
135
  process.exit(1);
125
136
  }
126
137
  });
127
- 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) => {
128
- const { shotCommand } = await import("./shot-By1AItpD.mjs");
138
+ cli.command("shot [...frames]", "Render frames headless and print the PNG paths (needs `dev` running): ids, --scene <name> or --all").option("--root <dir>", "Host repo root", { default: "." }).option("--scene <name>", "Every frame of one scene").option("--all", "Every frame of the canvas").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)").option("--json", "Print the results as JSON ({ results: [...] }) instead of paths").action(async (frames, opts) => {
139
+ const { shotCommand } = await import("./shot-BzQ0PXKH.mjs");
129
140
  try {
130
- await shotCommand(resolve(opts.root), frame, opts);
141
+ await shotCommand(resolve$1(opts.root), frames, opts);
131
142
  } catch (err) {
132
143
  console.error(`[${NAME}] ${err.message}`);
133
144
  process.exit(1);
@@ -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 { r as toFrameId } from "./manifest-CpbsqQ_v.mjs";
4
+ import { i as toFrameId } from "./manifest-CaslQIAO.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 { c as detectHost, o as loadConfig } from "./manifest-CpbsqQ_v.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BXyezwfN.mjs";
2
+ import { l as detectHost, s as loadConfig } from "./manifest-CaslQIAO.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-D2msH1cj.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";
@@ -255,9 +255,14 @@ async function dev(root, portFlag) {
255
255
  const port = typeof addr === "object" && addr ? addr.port : picked.port;
256
256
  if (picked.fellBack) console.log(`\n port ${desiredPort} is in use - serving "${projectName}" on ${port} instead`);
257
257
  console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
258
+ setTimeout(async () => {
259
+ try {
260
+ await (await import("./shot-DlmTO8AF.mjs")).sweepGhosts((m) => console.log(` ${m}`));
261
+ } catch {}
262
+ }, 0);
258
263
  try {
259
264
  const { staleManagedInstructions } = await import("./managed-HwHVNI3h.mjs");
260
- const { installedVersion } = await import("./plugin-BXyezwfN.mjs").then((n) => n.i);
265
+ const { installedVersion } = await import("./plugin-D2msH1cj.mjs").then((n) => n.i);
261
266
  const stale = staleManagedInstructions(root);
262
267
  if (stale.length) {
263
268
  const shown = stale.slice(0, 4).join(", ") + (stale.length > 4 ? `, +${stale.length - 4} more` : "");
@@ -295,7 +300,7 @@ async function dev(root, portFlag) {
295
300
  });
296
301
  }
297
302
  if (config.jam) {
298
- const { startJam } = await import("./daemon-Cb4JjpgL.mjs");
303
+ const { startJam } = await import("./daemon-Bbh_jmui.mjs");
299
304
  const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
300
305
  if (jam) {
301
306
  const close = server.close.bind(server);
@@ -1,5 +1,5 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
- import { a as DEFAULTS, c as detectHost, i as writeManifest, l as readJson, n as scanFrames, s as detectAgent } from "./manifest-CpbsqQ_v.mjs";
2
+ import { a as writeManifest, c as detectAgent, l as detectHost, n as scanFrames, o as DEFAULTS, u as readJson } from "./manifest-CaslQIAO.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 { basename, dirname, join, relative } from "node:path";
@@ -1,8 +1,9 @@
1
1
  import { r as PKG, t as CONTENT_WIDTH } from "./cli.mjs";
2
- import { d as isBoardName, f as readDescription, i as listBoardFiles, l as buildTree, m as hash, n as checkBoardsDir, o as readRegistry, t as boardFields, u as flatten } from "./boards-BdG1TJwU.mjs";
3
- import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { a as listBoardFiles, d as flatten, f as isBoardName, g as hash, m as readTitle, n as checkBoardsDir, p as readDescription, r as checkRealDirs, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BmxcT3Lc.mjs";
3
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { delimiter, isAbsolute, join, relative, sep } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
+ import { randomBytes } from "node:crypto";
6
7
  //#region src/server/detect.ts
7
8
  function readJson(file) {
8
9
  try {
@@ -476,29 +477,123 @@ function walk(dir, out = []) {
476
477
  }
477
478
  return out;
478
479
  }
479
- /** A scene's description is the first non-blank line of its `_brief.md` (a leading `#`
480
- * stripped, a YAML front-matter block skipped) - no new file. */
480
+ /** The YAML front matter at the top of a markdown file, read flat: `key: value` scalars,
481
+ * quoted (JSON-style double quotes, or single quotes with '' for one) or bare. `end` is the
482
+ * line the body starts at (0 = no block; an unclosed block swallows the file). */
483
+ function frontMatter(lines) {
484
+ const fields = {};
485
+ if (lines[0]?.trim() !== "---") return {
486
+ fields,
487
+ end: 0
488
+ };
489
+ let close = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
490
+ if (close < 0) close = lines.length;
491
+ for (const l of lines.slice(1, close)) {
492
+ const m = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(l.trim());
493
+ if (!m) continue;
494
+ let v = m[2].trim();
495
+ if (v.startsWith("\"")) try {
496
+ v = String(JSON.parse(v));
497
+ } catch {
498
+ v = v.replace(/^"|"$/g, "");
499
+ }
500
+ else if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1).replace(/''/g, "'");
501
+ fields[m[1]] = v;
502
+ }
503
+ return {
504
+ fields,
505
+ end: Math.min(close + 1, lines.length)
506
+ };
507
+ }
508
+ /** A scene's title is `title` in its `_brief.md` front matter; its description is the first
509
+ * non-blank line after the block (a leading `#` stripped) - no new file. */
481
510
  function sceneBrief(root, scene) {
482
511
  if (!scene) return {};
483
512
  const rel = `design/scenes/${scene}/_brief.md`;
484
513
  const abs = join(root, ...rel.split("/"));
485
514
  if (!existsSync(abs)) return {};
486
- let description;
515
+ let title, description;
487
516
  try {
488
- const lines = readFileSync(abs, "utf8").split(/\r?\n/).map((l) => l.trim());
489
- let i = 0;
490
- if (lines[0] === "---") {
491
- i = lines.indexOf("---", 1) + 1;
492
- if (i === 0) i = lines.length;
493
- }
494
- const line = lines.slice(i).find((l) => l);
517
+ const lines = readFileSync(abs, "utf8").replace(/^\uFEFF/, "").split(/\r?\n/);
518
+ const fm = frontMatter(lines);
519
+ title = readTitle(fm.fields.title);
520
+ const line = lines.slice(fm.end).map((l) => l.trim()).find((l) => l);
495
521
  description = readDescription(line?.replace(/^#+\s*/, ""));
496
522
  } catch {}
497
523
  return {
498
524
  brief: rel,
525
+ ...title ? { title } : {},
499
526
  ...description ? { description } : {}
500
527
  };
501
528
  }
529
+ /** Write a scene's title into its brief's front matter - only that line changes; the body,
530
+ * every other field and the file's own line endings are kept. No brief yet = a front-matter-only
531
+ * brief. An empty title removes the line, and the file when nothing else is in it. The write
532
+ * is atomic (temp + rename); a symlinked scene directory or brief, or a front matter block
533
+ * that never closes, is refused untouched - the brief is the agent's document, never rewritten
534
+ * from a guess. Returns the error, or null. */
535
+ function setSceneTitle(root, scene, title) {
536
+ const design = join(root, "design"), scenes = join(design, "scenes"), dir = join(scenes, scene);
537
+ const de = checkRealDirs(root, [
538
+ [design, "design"],
539
+ [scenes, "design/scenes"],
540
+ [dir, `design/scenes/${scene}`]
541
+ ]);
542
+ if (de) return de;
543
+ try {
544
+ if (!lstatSync(dir).isDirectory()) return `"${scene}" is not a scene directory`;
545
+ } catch {
546
+ return `scene "${scene}" does not exist`;
547
+ }
548
+ const file = join(dir, "_brief.md");
549
+ let had = false;
550
+ try {
551
+ if (!lstatSync(file).isFile()) return `design/scenes/${scene}/_brief.md is not a regular file - refusing to write it`;
552
+ had = true;
553
+ } catch {}
554
+ const text = had ? readFileSync(file, "utf8") : "";
555
+ const bom = text.startsWith("") ? "" : "";
556
+ const raw = text.slice(bom.length);
557
+ const eol = raw.includes("\r\n") ? "\r\n" : "\n";
558
+ const lines = raw.split(/\r?\n/);
559
+ const fm = frontMatter(lines);
560
+ if (fm.end === lines.length && lines[0]?.trim() === "---" && !lines.slice(1).some((l) => l.trim() === "---")) return `design/scenes/${scene}/_brief.md: the front matter never closes - fix the file`;
561
+ const line = title ? [`title: ${JSON.stringify(title)}`] : [];
562
+ let out;
563
+ if (fm.end === 0) {
564
+ if (!title) return null;
565
+ out = [
566
+ "---",
567
+ ...line,
568
+ "---",
569
+ ...raw.trim() ? ["", ...lines] : [""]
570
+ ];
571
+ } else {
572
+ const close = Math.max(1, fm.end - 1);
573
+ const kept = lines.slice(1, close).filter((l) => !/^title\s*:/.test(l.trim()));
574
+ const body = lines.slice(fm.end);
575
+ out = kept.length || title ? [
576
+ "---",
577
+ ...line,
578
+ ...kept,
579
+ "---",
580
+ ...body
581
+ ] : body;
582
+ }
583
+ if (!out.join("").trim()) {
584
+ if (had) rmSync(file);
585
+ return null;
586
+ }
587
+ const tmp = join(dir, `.brief-${randomBytes(6).toString("hex")}.tmp`);
588
+ try {
589
+ writeFileSync(tmp, bom + out.join(eol));
590
+ renameSync(tmp, file);
591
+ } catch (err) {
592
+ rmSync(tmp, { force: true });
593
+ throw err;
594
+ }
595
+ return null;
596
+ }
502
597
  /** The sidebar as files say it is, for the manifest: folders in root order, boards in
503
598
  * reading order with their folder and description. A boards dir we may not read (symlink)
504
599
  * or a malformed registry yields nothing here - the API and the build say why; the
@@ -517,14 +612,16 @@ function scanBoards(root) {
517
612
  for (const it of tree) if (it.kind === "folder") for (const b of it.boards) folderOf.set(b, it.name);
518
613
  const folders = tree.filter((it) => it.kind === "folder").map((it) => ({
519
614
  name: it.name,
615
+ ...it.title ? { title: it.title } : {},
520
616
  ...it.description ? { description: it.description } : {}
521
617
  }));
522
618
  const boards = flatten(tree).map((name) => {
523
- const d = rows.find((r) => r.name === name)?.description;
619
+ const r = rows.find((x) => x.name === name);
524
620
  return {
525
621
  name,
526
622
  ...folderOf.has(name) ? { folder: folderOf.get(name) } : {},
527
- ...d ? { description: d } : {}
623
+ ...r?.title ? { title: r.title } : {},
624
+ ...r?.description ? { description: r.description } : {}
528
625
  };
529
626
  });
530
627
  return {
@@ -682,4 +779,4 @@ function writeManifest(root, manifest) {
682
779
  return true;
683
780
  }
684
781
  //#endregion
685
- export { DEFAULTS as a, detectHost as c, writeManifest as i, readJson as l, scanFrames as n, loadConfig as o, toFrameId as r, detectAgent as s, affectedFrameIds as t };
782
+ export { writeManifest as a, detectAgent as c, toFrameId as i, detectHost as l, scanFrames as n, DEFAULTS as o, setSceneTitle as r, loadConfig as s, affectedFrameIds as t, readJson as u };