@marver-design/marver 0.15.0 → 0.16.1

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 (36) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +2 -1
  3. package/dist/boards-BmxcT3Lc.mjs +290 -0
  4. package/dist/boards-PuVzw5Wp.mjs +62 -0
  5. package/dist/{build-DfuTQZlY.mjs → build-B4yPgFNF.mjs} +72 -29
  6. package/dist/cli.mjs +33 -13
  7. package/dist/{daemon-DalgvoA9.mjs → daemon-Bbh_jmui.mjs} +1 -1
  8. package/dist/{dev-BxCmeU_H.mjs → dev-DH2W7Ffw.mjs} +4 -4
  9. package/dist/{init-QKNi9gvF.mjs → init-B7YhcN2o.mjs} +6 -3
  10. package/dist/{manifest-BzxSMoDB.mjs → manifest-CaslQIAO.mjs} +175 -12
  11. package/dist/{plugin-DJyjmQeh.mjs → plugin-BeBGu3gH.mjs} +239 -82
  12. package/dist/{poster-CbpzSzJu.mjs → poster-CoyobbGW.mjs} +1 -1
  13. package/dist/{shot-BWhoz6cU.mjs → shot-z-d-zMzf.mjs} +2 -2
  14. package/docs/publish.md +4 -1
  15. package/package.json +1 -1
  16. package/src/client/shell/App.tsx +45 -259
  17. package/src/client/shell/BoardList.tsx +438 -0
  18. package/src/client/shell/ContextMenu.tsx +59 -0
  19. package/src/client/shell/LockedApp.tsx +2 -1
  20. package/src/client/shell/Play.tsx +1 -0
  21. package/src/client/shell/icons.tsx +6 -0
  22. package/src/client/shell/labels.ts +4 -4
  23. package/src/client/shell/store.ts +156 -51
  24. package/src/client/shell/styles.css +40 -4
  25. package/src/shared/board-tree.ts +343 -0
  26. package/templates/AGENTS-embedded.md +25 -5
  27. package/templates/AGENTS-studio.md +25 -5
  28. package/templates/instructions/boards.md +91 -2
  29. package/templates/instructions/craft.md +4 -0
  30. package/templates/instructions/discover.md +20 -3
  31. package/templates/instructions/iterate.md +5 -4
  32. package/templates/instructions/review.md +4 -0
  33. package/templates/instructions/shape.md +1 -1
  34. package/templates/instructions/welcome.md +4 -1
  35. package/templates/instructions/wireframe.md +3 -0
  36. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
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-QKNi9gvF.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-BxCmeU_H.mjs");
74
+ const { dev } = await import("./dev-DH2W7Ffw.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-DfuTQZlY.mjs");
84
+ const { buildSite } = await import("./build-B4yPgFNF.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
- const { commentsCommand } = await import("./comments-DHB_8BRa.mjs");
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,7 +120,16 @@ 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);
124
+ } catch (err) {
125
+ console.error(`[${NAME}] ${err.message}`);
126
+ process.exit(1);
127
+ }
128
+ });
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) => {
130
+ const { boardsCommand } = await import("./boards-PuVzw5Wp.mjs");
131
+ try {
132
+ boardsCommand(resolve$1(opts.root), opts);
113
133
  } catch (err) {
114
134
  console.error(`[${NAME}] ${err.message}`);
115
135
  process.exit(1);
@@ -118,7 +138,7 @@ cli.command("work <action> [...frames]", "Working state on the canvas: start <sc
118
138
  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
139
  const { shotCommand } = await import("./shot-By1AItpD.mjs");
120
140
  try {
121
- await shotCommand(resolve(opts.root), frame, opts);
141
+ await shotCommand(resolve$1(opts.root), frame, opts);
122
142
  } catch (err) {
123
143
  console.error(`[${NAME}] ${err.message}`);
124
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 { i as toFrameId } from "./manifest-BzxSMoDB.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 { 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";
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-BeBGu3gH.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";
@@ -257,7 +257,7 @@ async function dev(root, portFlag) {
257
257
  console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
258
258
  try {
259
259
  const { staleManagedInstructions } = await import("./managed-HwHVNI3h.mjs");
260
- const { installedVersion } = await import("./plugin-DJyjmQeh.mjs").then((n) => n.i);
260
+ const { installedVersion } = await import("./plugin-BeBGu3gH.mjs").then((n) => n.i);
261
261
  const stale = staleManagedInstructions(root);
262
262
  if (stale.length) {
263
263
  const shown = stale.slice(0, 4).join(", ") + (stale.length > 4 ? `, +${stale.length - 4} more` : "");
@@ -295,7 +295,7 @@ async function dev(root, portFlag) {
295
295
  });
296
296
  }
297
297
  if (config.jam) {
298
- const { startJam } = await import("./daemon-DalgvoA9.mjs");
298
+ const { startJam } = await import("./daemon-Bbh_jmui.mjs");
299
299
  const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
300
300
  if (jam) {
301
301
  const close = server.close.bind(server);
@@ -1,8 +1,8 @@
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-BzxSMoDB.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
- import { dirname, join, relative } from "node:path";
5
+ import { basename, dirname, join, relative } from "node:path";
6
6
  //#region src/cli/init.ts
7
7
  /** Idempotent scaffolder: never overwrites existing files; every host-repo touch prints a diff. */
8
8
  function init(root, opts) {
@@ -221,7 +221,7 @@ function init(root, opts) {
221
221
  created.push("design/scenes/demo/ (3 frames)");
222
222
  }
223
223
  if (host.tsconfigSweepsDesign) patchTsconfigExclude(root);
224
- writeManifest(root, scanFrames(root));
224
+ writeManifest(root, scanFrames(root, { name: basename(root) }));
225
225
  console.log(`\n${NAME} initialized (${opts.mode} mode). Created:`);
226
226
  for (const f of created) console.log(` + ${f}`);
227
227
  if (host.router === "next") console.log(`\n note: Next.js support is partial - frames render outside Next, so next/font, next/image and Server Components do not exist inside them (details in design/AGENTS.md).`);
@@ -540,6 +540,9 @@ const configTemplate = (mode, jamAgent) => `// ${NAME} config - OPTIONAL. Delete
540
540
  // relative imports need extensions, tsconfig paths are ignored here.
541
541
  export default {
542
542
  mode: ${JSON.stringify(mode)},
543
+ // One sentence on what this product is and for whom. It lands in design/manifest.json
544
+ // as the project's description - the first thing a new agent session reads.
545
+ // description: "Analytics for indie SaaS - the founder's Monday-morning read",
543
546
  // Device widths for frames and the Devices view. Rename, retune, or uncomment tv.
544
547
  viewports: {
545
548
  mobile: { width: 390, height: 844 },
@@ -1,8 +1,9 @@
1
1
  import { r as PKG, t as CONTENT_WIDTH } from "./cli.mjs";
2
- 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";
3
4
  import { delimiter, isAbsolute, join, relative, sep } from "node:path";
4
5
  import { pathToFileURL } from "node:url";
5
- import { createHash } from "node:crypto";
6
+ import { randomBytes } from "node:crypto";
6
7
  //#region src/server/detect.ts
7
8
  function readJson(file) {
8
9
  try {
@@ -261,6 +262,7 @@ async function loadConfig(root) {
261
262
  themes: Array.isArray(user.themes) && user.themes.length ? user.themes.map(String) : DEFAULTS.themes,
262
263
  port: validPort(user.port) ?? DEFAULTS.port,
263
264
  zoomSpeed: validZoom(user.zoomSpeed) ?? DEFAULTS.zoomSpeed,
265
+ description: readDescription(user.description),
264
266
  share: {
265
267
  branding: user.share?.branding !== false,
266
268
  name: typeof user.share?.name === "string" ? user.share.name : void 0,
@@ -379,8 +381,8 @@ function extractMeta(src) {
379
381
  if (!m) return {};
380
382
  const body = m[1];
381
383
  const pick = (key) => {
382
- const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
383
- return r ? r[2] : void 0;
384
+ const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(?:'([^'\\n]*)'|"([^"\\n]*)"|\`([^\`\\n]*)\`)\\s*(?=[,}\\n]|$)`).exec(body);
385
+ return r ? r[1] ?? r[2] ?? r[3] : void 0;
384
386
  };
385
387
  const pickBool = (key) => {
386
388
  const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(true|false)\\b`).exec(body);
@@ -401,6 +403,8 @@ function extractMeta(src) {
401
403
  if (intent) out.intent = intent;
402
404
  const slide = pickBool("slide");
403
405
  if (slide !== void 0) out.slide = slide;
406
+ const description = readDescription(pick("description"));
407
+ if (description) out.description = description;
404
408
  return out;
405
409
  }
406
410
  /** Content-frame detection: LEXICAL by convention - the frame file itself imports
@@ -473,7 +477,159 @@ function walk(dir, out = []) {
473
477
  }
474
478
  return out;
475
479
  }
476
- function scanFrames(root) {
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. */
510
+ function sceneBrief(root, scene) {
511
+ if (!scene) return {};
512
+ const rel = `design/scenes/${scene}/_brief.md`;
513
+ const abs = join(root, ...rel.split("/"));
514
+ if (!existsSync(abs)) return {};
515
+ let title, description;
516
+ try {
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);
521
+ description = readDescription(line?.replace(/^#+\s*/, ""));
522
+ } catch {}
523
+ return {
524
+ brief: rel,
525
+ ...title ? { title } : {},
526
+ ...description ? { description } : {}
527
+ };
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
+ }
597
+ /** The sidebar as files say it is, for the manifest: folders in root order, boards in
598
+ * reading order with their folder and description. A boards dir we may not read (symlink)
599
+ * or a malformed registry yields nothing here - the API and the build say why; the
600
+ * orientation file must never fail to write. */
601
+ function scanBoards(root) {
602
+ const dir = join(root, "design", "boards");
603
+ if (checkBoardsDir(root, dir)) return {};
604
+ const reg = readRegistry(dir);
605
+ if (reg.state === "malformed") return {};
606
+ const rows = listBoardFiles(dir).boards.map((b) => ({
607
+ name: b.name,
608
+ ...boardFields(b.json, isBoardName)
609
+ }));
610
+ const tree = buildTree(rows, reg.folders);
611
+ const folderOf = /* @__PURE__ */ new Map();
612
+ for (const it of tree) if (it.kind === "folder") for (const b of it.boards) folderOf.set(b, it.name);
613
+ const folders = tree.filter((it) => it.kind === "folder").map((it) => ({
614
+ name: it.name,
615
+ ...it.title ? { title: it.title } : {},
616
+ ...it.description ? { description: it.description } : {}
617
+ }));
618
+ const boards = flatten(tree).map((name) => {
619
+ const r = rows.find((x) => x.name === name);
620
+ return {
621
+ name,
622
+ ...folderOf.has(name) ? { folder: folderOf.get(name) } : {},
623
+ ...r?.title ? { title: r.title } : {},
624
+ ...r?.description ? { description: r.description } : {}
625
+ };
626
+ });
627
+ return {
628
+ ...folders.length ? { folders } : {},
629
+ ...boards.length ? { boards } : {}
630
+ };
631
+ }
632
+ function scanFrames(root, project) {
477
633
  const design = join(root, "design");
478
634
  const frames = [];
479
635
  for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
@@ -502,6 +658,7 @@ function scanFrames(root) {
502
658
  if (meta.of) entry.variantGroup = meta.of;
503
659
  if (meta.variant) entry.variant = meta.variant;
504
660
  if (meta.slide) entry.slide = true;
661
+ if (meta.description) entry.description = meta.description;
505
662
  const content = contentScan(src);
506
663
  if (content || meta.intent) {
507
664
  entry.intent = meta.intent ?? content.intent;
@@ -527,12 +684,19 @@ function scanFrames(root) {
527
684
  inferVariantGroups(frames);
528
685
  const sceneCounts = /* @__PURE__ */ new Map();
529
686
  for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
687
+ const scenes = [...sceneCounts.entries()].map(([name, n]) => ({
688
+ name,
689
+ frames: n,
690
+ ...sceneBrief(root, name)
691
+ })).sort((a, b) => a.name.localeCompare(b.name));
530
692
  return {
531
- frames,
532
- scenes: [...sceneCounts.entries()].map(([name, n]) => ({
533
- name,
534
- frames: n
535
- })).sort((a, b) => a.name.localeCompare(b.name))
693
+ ...project && (project.name || project.description) ? { project: {
694
+ ...project.name ? { name: project.name } : {},
695
+ ...project.description ? { description: project.description } : {}
696
+ } } : {},
697
+ ...scanBoards(root),
698
+ scenes,
699
+ frames
536
700
  };
537
701
  }
538
702
  /** Variant groups. A group = 2+ frames in one DIRECTORY whose basenames
@@ -614,6 +778,5 @@ function writeManifest(root, manifest) {
614
778
  writeFileSync(file, next);
615
779
  return true;
616
780
  }
617
- const hash = (s) => createHash("sha256").update(s).digest("hex");
618
781
  //#endregion
619
- export { writeManifest as a, detectAgent as c, toFrameId as i, detectHost as l, hash as n, DEFAULTS as o, scanFrames as r, loadConfig as s, affectedFrameIds as t, readJson as u };
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 };