@marver-design/marver 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +7 -4
  3. package/dist/boards-6hKVW42a.mjs +57 -0
  4. package/dist/boards-BdG1TJwU.mjs +267 -0
  5. package/dist/{build-ByYafIhj.mjs → build-Ct0iMoSi.mjs} +77 -30
  6. package/dist/cli.mjs +15 -6
  7. package/dist/{daemon-Bfucyf1o.mjs → daemon-Cb4JjpgL.mjs} +1 -1
  8. package/dist/{dev-yZMyQeUj.mjs → dev-CdcIuhZJ.mjs} +14 -4
  9. package/dist/{init-Dvaso7YO.mjs → init-BQIpIpIv.mjs} +6 -3
  10. package/dist/{manifest-DvOmglFp.mjs → manifest-CpbsqQ_v.mjs} +94 -17
  11. package/dist/{plugin-BsmG5i2X.mjs → plugin-BXyezwfN.mjs} +201 -74
  12. package/dist/poster-DOY7pax8.mjs +143 -0
  13. package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
  14. package/dist/{shot-kbR_xzJH.mjs → shot-CwmHO5T4.mjs} +197 -56
  15. package/docs/live-jam.md +6 -2
  16. package/docs/publish.md +4 -1
  17. package/docs/slides.md +9 -3
  18. package/package.json +1 -1
  19. package/src/client/content/chart.tsx +62 -24
  20. package/src/client/content/index.tsx +5 -2
  21. package/src/client/content/video.tsx +132 -35
  22. package/src/client/frame-host/bridge.js +6 -1
  23. package/src/client/shell/App.tsx +33 -247
  24. package/src/client/shell/BoardList.tsx +408 -0
  25. package/src/client/shell/ContextMenu.tsx +59 -0
  26. package/src/client/shell/icons.tsx +7 -0
  27. package/src/client/shell/store.ts +156 -48
  28. package/src/client/shell/styles.css +42 -4
  29. package/src/shared/board-tree.ts +285 -0
  30. package/templates/AGENTS-embedded.md +35 -7
  31. package/templates/AGENTS-studio.md +35 -7
  32. package/templates/instructions/boards.md +120 -7
  33. package/templates/instructions/craft.md +21 -0
  34. package/templates/instructions/discover.md +7 -2
  35. package/templates/instructions/iterate.md +114 -18
  36. package/templates/instructions/jam.md +18 -2
  37. package/templates/instructions/review.md +4 -0
  38. package/templates/instructions/shape.md +16 -2
  39. package/templates/instructions/slides.md +5 -1
  40. package/templates/instructions/welcome.md +4 -1
  41. package/templates/instructions/wireframe.md +3 -0
  42. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
@@ -1,7 +1,8 @@
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";
4
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
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";
5
+ import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
6
  import { basename, dirname, join, sep } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { createHash, randomBytes } from "node:crypto";
@@ -44,8 +45,9 @@ function scanAssetRefs(src, moduleId) {
44
45
  const srcM = /\bsrc\s*=\s*["']([^"']+)["']/.exec(tag);
45
46
  const posterM = /\bposter\s*=\s*["']([^"']+)["']/.exec(tag);
46
47
  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="...".`);
48
+ const remote = /^https:\/\//.test(srcM[1]);
48
49
  out.push(srcM[1]);
50
+ if (!remote && !posterM) out.push(`${srcM[1]}.poster.png`);
49
51
  }
50
52
  if (posterM) out.push(posterM[1]);
51
53
  }
@@ -147,22 +149,67 @@ function resolvePolicy(root, allBoards, boardsFlag, allBoardsFlag) {
147
149
  reveal
148
150
  };
149
151
  }
150
- /** Read every board file; returns name -> parsed json. Bad JSON fails the build loudly. */
152
+ /** Read every board file; returns name -> parsed json. Bad JSON fails the build loudly, and so
153
+ * does a board-named entry that is not a regular file (a symlink could publish JSON from
154
+ * outside the project - the build fails closed rather than skipping it quietly). */
151
155
  function readBoards(root) {
152
156
  const dir = join(root, "design", "boards");
157
+ const de = checkBoardsDir(root, dir);
158
+ if (de) throw new Error(de);
159
+ const { boards, skipped } = listBoardFiles(dir);
160
+ if (skipped.length) throw new Error(`design/boards/${skipped[0]} is not a regular file (a symlinked board cannot be published)`);
153
161
  const out = {};
154
- if (!existsSync(dir)) return out;
155
- for (const f of readdirSync(dir)) {
156
- if (!f.endsWith(".json")) continue;
157
- const name = f.replace(/\.json$/, "");
158
- try {
159
- out[name] = JSON.parse(readFileSync(join(dir, f), "utf8"));
160
- } catch {
161
- throw new Error(`design/boards/${f} is not valid JSON`);
162
- }
162
+ for (const b of boards) {
163
+ if (b.json === null) throw new Error(`design/boards/${b.name}.json is not valid JSON`);
164
+ out[b.name] = b.json;
163
165
  }
164
166
  return out;
165
167
  }
168
+ /** Switcher order = the sidebar's reading order: the folder tree over the PUBLISHED boards only
169
+ * (a folder left with no published board drops out - its name never reaches the bundle),
170
+ * flattened depth-first; all-scenes always LAST (it is the expensive everything-board, never
171
+ * the landing). `names[0]` is where `/` opens. Folder names of published boards are structure,
172
+ * like board names: they ship. */
173
+ function publishedTree(published, allBoards, folders) {
174
+ const field = (n, k) => allBoards[n]?.[k];
175
+ const tree = buildTree(published.filter((n) => n !== "all-scenes").map((n) => ({
176
+ name: n,
177
+ order: field(n, "order"),
178
+ folder: field(n, "folder")
179
+ })), folders).filter((it) => it.kind === "board" || it.boards.length > 0);
180
+ return {
181
+ tree,
182
+ names: [...flatten(tree), ...published.includes("all-scenes") ? ["all-scenes"] : []]
183
+ };
184
+ }
185
+ /** The bundle's manifest: the published frames, and descriptions of published things ONLY -
186
+ * the project's, the published scenes' (their brief path only when source is revealed),
187
+ * the published boards' and the folders those sit in. No unpublished name or sentence. */
188
+ function publishedManifest(manifest, pubFrames, publishedNames, strip) {
189
+ const pubScenes = new Set(pubFrames.map((f) => f.scene));
190
+ const pubBoardSet = new Set(publishedNames);
191
+ const pubBoards = (manifest.boards ?? []).filter((b) => pubBoardSet.has(b.name));
192
+ const pubFolderSet = new Set(pubBoards.map((b) => b.folder).filter(Boolean));
193
+ const pubFolders = (manifest.folders ?? []).filter((f) => pubFolderSet.has(f.name));
194
+ return {
195
+ ...manifest.project ? { project: manifest.project } : {},
196
+ ...pubFolders.length ? { folders: pubFolders } : {},
197
+ ...pubBoards.length ? { boards: pubBoards } : {},
198
+ scenes: manifest.scenes.filter((s) => pubScenes.has(s.name)).map(({ name, description, brief }) => ({
199
+ name,
200
+ frames: pubFrames.filter((f) => f.scene === name).length,
201
+ ...description ? { description } : {},
202
+ ...brief && !strip ? { brief } : {}
203
+ })),
204
+ frames: pubFrames
205
+ };
206
+ }
207
+ /** The folder registry: absent = no folders (boards still imply theirs); malformed fails the build. */
208
+ function readFolders(root) {
209
+ const reg = readRegistry(join(root, "design", "boards"));
210
+ if (reg.state === "malformed") throw new Error(reg.error);
211
+ return reg.folders;
212
+ }
166
213
  /**
167
214
  * The generated registry with OPAQUE keys - the source strip's half of the
168
215
  * bundle (01-sharing §6.2). Object keys are string literals that survive
@@ -266,18 +313,17 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
266
313
  const pkgDir = packageDir();
267
314
  const clientDir = join(pkgDir, "src", "client");
268
315
  const outDir = join(root, "design", ".dist");
269
- const manifest = scanFrames(root);
316
+ const manifest = scanFrames(root, {
317
+ name: config.share.name || basename(root),
318
+ description: config.description
319
+ });
270
320
  const allBoards = readBoards(root);
271
321
  const policy = resolvePolicy(root, allBoards, boardsFlag, allBoardsFlag);
272
322
  const rights = Object.fromEntries(Object.entries(policy.boards).map(([n, p]) => [n, p.max]));
273
323
  const strip = !policy.reveal.source;
274
324
  const buildSalt = randomBytes(16).toString("hex");
275
325
  const opaquePath = (id, ext) => `__mv/f/mv${createHash("sha256").update(buildSalt).update(id).digest("hex").slice(0, 12)}${ext}`;
276
- const boardOrder = (n) => {
277
- const o = allBoards[n]?.order;
278
- return typeof o === "number" && Number.isFinite(o) ? o : Infinity;
279
- };
280
- const publishedNames = Object.keys(rights).sort((a, b) => a === "all-scenes" ? 1 : b === "all-scenes" ? -1 : boardOrder(a) - boardOrder(b) || a.localeCompare(b));
326
+ const { tree, names: publishedNames } = publishedTree(Object.keys(rights), allBoards, readFolders(root));
281
327
  const includeAll = publishedNames.includes("all-scenes");
282
328
  const boards = {};
283
329
  for (const n of publishedNames) if (allBoards[n]) boards[n] = allBoards[n];
@@ -287,17 +333,10 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
287
333
  for (const b of Object.values(boards)) for (const node of Array.isArray(b?.nodes) ? b.nodes : []) if (typeof node?.frame === "string") wanted.add(node.frame);
288
334
  frames = manifest.frames.filter((f) => wanted.has(f.id));
289
335
  }
290
- const pubFrames = strip ? frames.map((f) => ({
336
+ const pubManifest = publishedManifest(manifest, strip ? frames.map((f) => ({
291
337
  ...f,
292
338
  file: opaquePath(f.id, f.kind === "html" ? ".html" : "")
293
- })) : frames;
294
- const pubManifest = {
295
- frames: pubFrames,
296
- scenes: [...new Set(pubFrames.map((f) => f.scene))].sort().map((name) => ({
297
- name,
298
- frames: pubFrames.filter((f) => f.scene === name).length
299
- }))
300
- };
339
+ })) : frames, publishedNames, strip);
301
340
  for (const n of publishedNames) {
302
341
  const pb = policy.boards[n];
303
342
  if (pb?.type !== "slides" && pb?.open !== "slides") continue;
@@ -329,6 +368,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
329
368
  manifest: pubManifest,
330
369
  boards,
331
370
  names: publishedNames,
371
+ tree,
332
372
  default: publishedNames.find((n) => n !== "all-scenes") ?? publishedNames[0],
333
373
  rights,
334
374
  policy: {
@@ -458,6 +498,13 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
458
498
  }
459
499
  let copiedAssets = 0;
460
500
  const realAssets = existsSync(assetsDir) ? realpathSync(assetsDir) : null;
501
+ for (const r of refs) {
502
+ if (!isLocalAssetRef(r) || !r.endsWith(".poster.png") || existsSync(join(assetsDir, r))) continue;
503
+ const { ensurePoster } = await import("./poster-DOY7pax8.mjs");
504
+ const g = await ensurePoster(assetsDir, r.slice(0, -11));
505
+ if (!g.ok) throw new Error(`design/assets/${r}: ${g.error}`);
506
+ console.log(` poster: rendered design/assets/${r} (${g.width}×${g.height})`);
507
+ }
461
508
  for (const r of refs) {
462
509
  if (!isLocalAssetRef(r)) continue;
463
510
  const srcFile = join(assetsDir, r);
@@ -526,4 +573,4 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
526
573
  console.log(`\n serve it: npx ${NAME} serve (set MARVER_PASSWORD to gate it)\n`);
527
574
  }
528
575
  //#endregion
529
- export { buildSite };
576
+ 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-BQIpIpIv.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-CdcIuhZJ.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-Ct0iMoSi.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);
@@ -89,7 +89,7 @@ cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").opti
89
89
  serve(resolve(opts.root), port);
90
90
  });
91
91
  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");
92
+ const { commentsCommand } = await import("./comments-oYcZ3cE-.mjs");
93
93
  try {
94
94
  await commentsCommand(resolve(opts.root), action, value, opts);
95
95
  } catch (err) {
@@ -115,8 +115,17 @@ 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("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");
120
+ try {
121
+ boardsCommand(resolve(opts.root), opts);
122
+ } catch (err) {
123
+ console.error(`[${NAME}] ${err.message}`);
124
+ process.exit(1);
125
+ }
126
+ });
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");
120
129
  try {
121
130
  await shotCommand(resolve(opts.root), frame, opts);
122
131
  } 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 { r as toFrameId } from "./manifest-CpbsqQ_v.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 { 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";
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-BXyezwfN.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-Cb4JjpgL.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,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-DvOmglFp.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";
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,8 @@
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";
2
3
  import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, 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
6
  //#region src/server/detect.ts
7
7
  function readJson(file) {
8
8
  try {
@@ -261,6 +261,7 @@ async function loadConfig(root) {
261
261
  themes: Array.isArray(user.themes) && user.themes.length ? user.themes.map(String) : DEFAULTS.themes,
262
262
  port: validPort(user.port) ?? DEFAULTS.port,
263
263
  zoomSpeed: validZoom(user.zoomSpeed) ?? DEFAULTS.zoomSpeed,
264
+ description: readDescription(user.description),
264
265
  share: {
265
266
  branding: user.share?.branding !== false,
266
267
  name: typeof user.share?.name === "string" ? user.share.name : void 0,
@@ -379,8 +380,8 @@ function extractMeta(src) {
379
380
  if (!m) return {};
380
381
  const body = m[1];
381
382
  const pick = (key) => {
382
- const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
383
- return r ? r[2] : void 0;
383
+ const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(?:'([^'\\n]*)'|"([^"\\n]*)"|\`([^\`\\n]*)\`)\\s*(?=[,}\\n]|$)`).exec(body);
384
+ return r ? r[1] ?? r[2] ?? r[3] : void 0;
384
385
  };
385
386
  const pickBool = (key) => {
386
387
  const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(true|false)\\b`).exec(body);
@@ -401,22 +402,35 @@ function extractMeta(src) {
401
402
  if (intent) out.intent = intent;
402
403
  const slide = pickBool("slide");
403
404
  if (slide !== void 0) out.slide = slide;
405
+ const description = readDescription(pick("description"));
406
+ if (description) out.description = description;
404
407
  return out;
405
408
  }
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. */
409
+ /** Content-frame detection: LEXICAL by convention - the frame file itself imports
410
+ * PKG/content (an import specifier scan, so "<Diagram" inside a string in a UI frame can
411
+ * never misbadge it; barrels/re-exports are not detected - meta.intent is the taught path
412
+ * and always works) AND renders a DOCUMENT primitive: Doc, Md, Diagram or Img (reference
413
+ * imagery - a moodboard is Rows of Img). The import alone is not enough - Chart, Video, Row,
414
+ * Col and Space are shared blocks a UI screen or a slide uses too, and a dashboard with one
415
+ * <Chart> is still a screen (device height, no document measuring), never a spec. Returns
416
+ * the inferred intent + natural width, or null for UI frames. */
411
417
  const CONTENT_IMPORT = new RegExp(`from\\s+['"]${PKG}/content['"]`);
412
418
  const WIDE_DOC = /<Doc\b[^>]*\blayout\s*=\s*["']wide["']/;
413
419
  const contentWidthOf = (src) => WIDE_DOC.test(src) ? CONTENT_WIDTH.wide : CONTENT_WIDTH.document;
420
+ /** The source with comments, template literals and CODE-shaped strings blanked (a JSX
421
+ * expression string `{'<Diagram>'}`, an attribute value), so a tag name quoted in an example
422
+ * or a comment never counts as a rendered element. Prose is left alone: JSX text has
423
+ * apostrophes ("it's"), and a blanket quote match would swallow real tags between two of them. */
424
+ 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
425
  function contentScan(src) {
415
426
  if (!CONTENT_IMPORT.test(src)) return null;
416
- const count = (re) => (src.match(re) ?? []).length;
427
+ const code = codeOnly(src);
428
+ const count = (re) => (code.match(re) ?? []).length;
429
+ const docs = count(/<Doc[\s>/]/g);
417
430
  const diagrams = count(/<Diagram[\s>/]/g);
418
431
  const imgs = count(/<Img[\s>/]/g);
419
432
  const mds = count(/<Md[\s>/]/g);
433
+ if (docs + diagrams + mds + imgs === 0) return null;
420
434
  return {
421
435
  intent: diagrams > 0 ? "diagram" : imgs > mds ? "moodboard" : "spec",
422
436
  width: contentWidthOf(src)
@@ -462,7 +476,63 @@ function walk(dir, out = []) {
462
476
  }
463
477
  return out;
464
478
  }
465
- function scanFrames(root) {
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. */
481
+ function sceneBrief(root, scene) {
482
+ if (!scene) return {};
483
+ const rel = `design/scenes/${scene}/_brief.md`;
484
+ const abs = join(root, ...rel.split("/"));
485
+ if (!existsSync(abs)) return {};
486
+ let description;
487
+ 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);
495
+ description = readDescription(line?.replace(/^#+\s*/, ""));
496
+ } catch {}
497
+ return {
498
+ brief: rel,
499
+ ...description ? { description } : {}
500
+ };
501
+ }
502
+ /** The sidebar as files say it is, for the manifest: folders in root order, boards in
503
+ * reading order with their folder and description. A boards dir we may not read (symlink)
504
+ * or a malformed registry yields nothing here - the API and the build say why; the
505
+ * orientation file must never fail to write. */
506
+ function scanBoards(root) {
507
+ const dir = join(root, "design", "boards");
508
+ if (checkBoardsDir(root, dir)) return {};
509
+ const reg = readRegistry(dir);
510
+ if (reg.state === "malformed") return {};
511
+ const rows = listBoardFiles(dir).boards.map((b) => ({
512
+ name: b.name,
513
+ ...boardFields(b.json, isBoardName)
514
+ }));
515
+ const tree = buildTree(rows, reg.folders);
516
+ const folderOf = /* @__PURE__ */ new Map();
517
+ for (const it of tree) if (it.kind === "folder") for (const b of it.boards) folderOf.set(b, it.name);
518
+ const folders = tree.filter((it) => it.kind === "folder").map((it) => ({
519
+ name: it.name,
520
+ ...it.description ? { description: it.description } : {}
521
+ }));
522
+ const boards = flatten(tree).map((name) => {
523
+ const d = rows.find((r) => r.name === name)?.description;
524
+ return {
525
+ name,
526
+ ...folderOf.has(name) ? { folder: folderOf.get(name) } : {},
527
+ ...d ? { description: d } : {}
528
+ };
529
+ });
530
+ return {
531
+ ...folders.length ? { folders } : {},
532
+ ...boards.length ? { boards } : {}
533
+ };
534
+ }
535
+ function scanFrames(root, project) {
466
536
  const design = join(root, "design");
467
537
  const frames = [];
468
538
  for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
@@ -491,6 +561,7 @@ function scanFrames(root) {
491
561
  if (meta.of) entry.variantGroup = meta.of;
492
562
  if (meta.variant) entry.variant = meta.variant;
493
563
  if (meta.slide) entry.slide = true;
564
+ if (meta.description) entry.description = meta.description;
494
565
  const content = contentScan(src);
495
566
  if (content || meta.intent) {
496
567
  entry.intent = meta.intent ?? content.intent;
@@ -516,12 +587,19 @@ function scanFrames(root) {
516
587
  inferVariantGroups(frames);
517
588
  const sceneCounts = /* @__PURE__ */ new Map();
518
589
  for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
590
+ const scenes = [...sceneCounts.entries()].map(([name, n]) => ({
591
+ name,
592
+ frames: n,
593
+ ...sceneBrief(root, name)
594
+ })).sort((a, b) => a.name.localeCompare(b.name));
519
595
  return {
520
- frames,
521
- scenes: [...sceneCounts.entries()].map(([name, n]) => ({
522
- name,
523
- frames: n
524
- })).sort((a, b) => a.name.localeCompare(b.name))
596
+ ...project && (project.name || project.description) ? { project: {
597
+ ...project.name ? { name: project.name } : {},
598
+ ...project.description ? { description: project.description } : {}
599
+ } } : {},
600
+ ...scanBoards(root),
601
+ scenes,
602
+ frames
525
603
  };
526
604
  }
527
605
  /** Variant groups. A group = 2+ frames in one DIRECTORY whose basenames
@@ -603,6 +681,5 @@ function writeManifest(root, manifest) {
603
681
  writeFileSync(file, next);
604
682
  return true;
605
683
  }
606
- const hash = (s) => createHash("sha256").update(s).digest("hex");
607
684
  //#endregion
608
- 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 };
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 };