@marver-design/marver 0.15.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 (33) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +2 -1
  3. package/dist/boards-6hKVW42a.mjs +57 -0
  4. package/dist/boards-BdG1TJwU.mjs +267 -0
  5. package/dist/{build-DfuTQZlY.mjs → build-Ct0iMoSi.mjs} +68 -29
  6. package/dist/cli.mjs +13 -4
  7. package/dist/{daemon-DalgvoA9.mjs → daemon-Cb4JjpgL.mjs} +1 -1
  8. package/dist/{dev-BxCmeU_H.mjs → dev-CdcIuhZJ.mjs} +4 -4
  9. package/dist/{init-QKNi9gvF.mjs → init-BQIpIpIv.mjs} +6 -3
  10. package/dist/{manifest-BzxSMoDB.mjs → manifest-CpbsqQ_v.mjs} +77 -11
  11. package/dist/{plugin-DJyjmQeh.mjs → plugin-BXyezwfN.mjs} +164 -69
  12. package/dist/{poster-CbpzSzJu.mjs → poster-DOY7pax8.mjs} +1 -1
  13. package/dist/{shot-BWhoz6cU.mjs → shot-CwmHO5T4.mjs} +2 -2
  14. package/docs/publish.md +4 -1
  15. package/package.json +1 -1
  16. package/src/client/shell/App.tsx +4 -246
  17. package/src/client/shell/BoardList.tsx +408 -0
  18. package/src/client/shell/ContextMenu.tsx +59 -0
  19. package/src/client/shell/icons.tsx +6 -0
  20. package/src/client/shell/store.ts +105 -47
  21. package/src/client/shell/styles.css +36 -4
  22. package/src/shared/board-tree.ts +285 -0
  23. package/templates/AGENTS-embedded.md +18 -5
  24. package/templates/AGENTS-studio.md +18 -5
  25. package/templates/instructions/boards.md +73 -2
  26. package/templates/instructions/craft.md +4 -0
  27. package/templates/instructions/discover.md +7 -2
  28. package/templates/instructions/iterate.md +5 -4
  29. package/templates/instructions/review.md +4 -0
  30. package/templates/instructions/shape.md +1 -1
  31. package/templates/instructions/welcome.md +4 -1
  32. package/templates/instructions/wireframe.md +3 -0
  33. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
@@ -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 { 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";
@@ -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-BXyezwfN.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-Cb4JjpgL.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 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,6 +402,8 @@ 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
409
  /** Content-frame detection: LEXICAL by convention - the frame file itself imports
@@ -473,7 +476,63 @@ function walk(dir, out = []) {
473
476
  }
474
477
  return out;
475
478
  }
476
- 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) {
477
536
  const design = join(root, "design");
478
537
  const frames = [];
479
538
  for (const base of ["scenes", "components"]) for (const abs of walk(join(design, base))) {
@@ -502,6 +561,7 @@ function scanFrames(root) {
502
561
  if (meta.of) entry.variantGroup = meta.of;
503
562
  if (meta.variant) entry.variant = meta.variant;
504
563
  if (meta.slide) entry.slide = true;
564
+ if (meta.description) entry.description = meta.description;
505
565
  const content = contentScan(src);
506
566
  if (content || meta.intent) {
507
567
  entry.intent = meta.intent ?? content.intent;
@@ -527,12 +587,19 @@ function scanFrames(root) {
527
587
  inferVariantGroups(frames);
528
588
  const sceneCounts = /* @__PURE__ */ new Map();
529
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));
530
595
  return {
531
- frames,
532
- scenes: [...sceneCounts.entries()].map(([name, n]) => ({
533
- name,
534
- frames: n
535
- })).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
536
603
  };
537
604
  }
538
605
  /** Variant groups. A group = 2+ frames in one DIRECTORY whose basenames
@@ -614,6 +681,5 @@ function writeManifest(root, manifest) {
614
681
  writeFileSync(file, next);
615
682
  return true;
616
683
  }
617
- const hash = (s) => createHash("sha256").update(s).digest("hex");
618
684
  //#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 };
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 };
@@ -1,13 +1,13 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
3
+ import { a as nodeExists, c as FOLDERS_FILE, f as readDescription, i as listBoardFiles, m as hash, n as checkBoardsDir, o as readRegistry, p as validateWire, r as isRegularFile, s as BOARD_NAME, t as boardFields } from "./boards-BdG1TJwU.mjs";
3
4
  import { n as localProfile, t as isConnected } from "./profile-BjAPAJSb.mjs";
4
- import { a as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-BzxSMoDB.mjs";
5
- import { copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
5
+ import { i as writeManifest, n as scanFrames, o as loadConfig, t as affectedFrameIds } from "./manifest-CpbsqQ_v.mjs";
6
+ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
6
7
  import { basename, dirname, join, resolve, sep } from "node:path";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { randomBytes } from "node:crypto";
9
10
  //#region src/server/api.ts
10
- const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
11
11
  const BODY_LIMIT = 1e6;
12
12
  const CSRF_MAX_AGE = 2592e3;
13
13
  /** Read one cookie value off the request. */
@@ -85,37 +85,18 @@ function contained(target, base) {
85
85
  }
86
86
  function apiMiddleware(root, opts = {}) {
87
87
  const boardsDir = join(root, "design", "boards");
88
+ const foldersPath = join(boardsDir, FOLDERS_FILE);
88
89
  const boardPath = (name) => {
89
- if (!BOARD_NAME.test(name)) return null;
90
+ if (!validName(name)) return null;
91
+ if (checkBoardsDir(root, boardsDir)) return null;
90
92
  mkdirSync(boardsDir, { recursive: true });
91
93
  const p = resolve(boardsDir, `${name}.json`);
92
94
  return contained(p, boardsDir) ? p : null;
93
95
  };
94
96
  const validName = (n) => typeof n === "string" && n.length >= 1 && n.length <= 64 && BOARD_NAME.test(n);
95
- const underRoot = (dir) => {
96
- try {
97
- const rr = realpathSync(root);
98
- const rd = realpathSync(dir);
99
- return rd === rr || rd.startsWith(rr + sep);
100
- } catch {
101
- return false;
102
- }
103
- };
104
- const notSymlink = (p) => {
105
- try {
106
- return !existsSync(p) || !lstatSync(p).isSymbolicLink();
107
- } catch {
108
- return false;
109
- }
110
- };
111
- const nodeExists = (p) => {
112
- try {
113
- lstatSync(p);
114
- return true;
115
- } catch {
116
- return false;
117
- }
118
- };
97
+ const dirError = () => checkBoardsDir(root, boardsDir);
98
+ const notSymlink = (p) => !nodeExists(p) || isRegularFile(p);
99
+ const nodeExists$1 = nodeExists;
119
100
  try {
120
101
  for (const f of readdirSync(boardsDir)) if (f.endsWith(".tmp")) rmSync(join(boardsDir, f), { force: true });
121
102
  } catch {}
@@ -144,20 +125,24 @@ function apiMiddleware(root, opts = {}) {
144
125
  return json(res, 200, { boards: {} });
145
126
  }
146
127
  if (path === "boards" && req.method === "GET") {
128
+ const de = dirError();
129
+ if (de) return json(res, 422, { error: de });
147
130
  mkdirSync(boardsDir, { recursive: true });
148
- return json(res, 200, readdirSync(boardsDir).filter((f) => f.endsWith(".json") && !f.endsWith(".tmp")).map((f) => {
149
- const content = readFileSync(join(boardsDir, f), "utf8");
150
- let order;
151
- try {
152
- const o = JSON.parse(content)?.order;
153
- if (typeof o === "number" && Number.isFinite(o)) order = o;
154
- } catch {}
155
- return {
156
- name: f.replace(/\.json$/, ""),
157
- sha256: hash(content),
158
- order
159
- };
160
- }));
131
+ return json(res, 200, listBoardFiles(boardsDir).boards.map((b) => ({
132
+ name: b.name,
133
+ sha256: b.sha256,
134
+ ...boardFields(b.json, validName)
135
+ })));
136
+ }
137
+ if (path === "folders" && req.method === "GET") {
138
+ const de = dirError();
139
+ if (de) return json(res, 422, { error: de });
140
+ const reg = readRegistry(boardsDir);
141
+ if (reg.state === "malformed") return json(res, 422, { error: reg.error });
142
+ return json(res, 200, {
143
+ folders: reg.folders,
144
+ sha256: reg.sha256
145
+ });
161
146
  }
162
147
  if (path === "boards/rename" && req.method === "POST") {
163
148
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
@@ -173,21 +158,24 @@ function apiMiddleware(root, opts = {}) {
173
158
  const { from, to } = parsed;
174
159
  if (!validName(from) || !validName(to)) return json(res, 400, { error: "invalid board name" });
175
160
  if (to === "all-scenes" || from === "all-scenes" || to === from) return json(res, 400, { error: "invalid rename" });
176
- if (!underRoot(boardsDir)) return json(res, 400, { error: "boards directory escapes the project" });
161
+ {
162
+ const de = dirError();
163
+ if (de) return json(res, 400, { error: de });
164
+ }
177
165
  const fromPath = boardPath(from), toPath = boardPath(to);
178
166
  if (!fromPath || !toPath) return json(res, 400, { error: "invalid board name" });
179
167
  if (!existsSync(fromPath)) return json(res, 404, { error: `board "${from}" does not exist` });
180
168
  if (!notSymlink(fromPath)) return json(res, 400, { error: "refusing to rename a symlinked board file" });
181
169
  if (isConnected(root)) return json(res, 409, { error: "disconnect before renaming boards - comment sync is board-keyed" });
182
170
  if (existsSync(join(root, "design", "comments", `${from}.jsonl`))) return json(res, 409, { error: "rename a board before commenting on it - this board has threads" });
183
- if (nodeExists(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
171
+ if (nodeExists$1(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
184
172
  try {
185
173
  linkSync(fromPath, toPath);
186
174
  } catch (err) {
187
175
  const code = err.code;
188
176
  if (code === "EEXIST") return json(res, 409, { error: `a board named "${to}" already exists` });
189
177
  if (code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP" && code !== "EOPNOTSUPP") throw err;
190
- if (nodeExists(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
178
+ if (nodeExists$1(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
191
179
  renameSync(fromPath, toPath);
192
180
  return json(res, 200, { name: to });
193
181
  }
@@ -205,25 +193,104 @@ function apiMiddleware(root, opts = {}) {
205
193
  return json(res, 400, { error: "malformed JSON" });
206
194
  }
207
195
  if (!parsed || typeof parsed !== "object") return json(res, 400, { error: "expected an object" });
208
- const order = parsed.order;
209
- if (!Array.isArray(order) || order.length < 1 || order.length > 200) return json(res, 400, { error: "invalid order" });
210
- if (!order.every(validName) || order.some((n) => n === "all-scenes")) return json(res, 400, { error: "invalid board name in order" });
211
- if (new Set(order).size !== order.length) return json(res, 400, { error: "duplicate board in order" });
212
- if (!underRoot(boardsDir)) return json(res, 400, { error: "boards directory escapes the project" });
213
- for (let i = 0; i < order.length; i++) {
214
- const p = boardPath(order[i]);
215
- if (!p || !existsSync(p) || !notSymlink(p)) continue;
196
+ const { tree, base } = parsed;
197
+ const bad = validateWire(tree);
198
+ if (bad) return json(res, 400, { error: bad });
199
+ const b = base;
200
+ if (!b || typeof b !== "object" || !b.boards || typeof b.boards !== "object" || b.folders !== null && typeof b.folders !== "string") return json(res, 400, { error: "expected base hashes" });
201
+ {
202
+ const de = dirError();
203
+ if (de) return json(res, 400, { error: de });
204
+ }
205
+ const reg = readRegistry(boardsDir);
206
+ if (reg.state === "malformed") return json(res, 422, { error: reg.error });
207
+ if (reg.sha256 !== b.folders) return json(res, 409, {
208
+ error: "folders changed on disk",
209
+ stale: [FOLDERS_FILE]
210
+ });
211
+ const unseen = listBoardFiles(boardsDir).boards.map((x) => x.name).filter((n) => !(n in b.boards));
212
+ if (unseen.length) return json(res, 409, {
213
+ error: "boards changed on disk",
214
+ stale: unseen
215
+ });
216
+ const plan = [];
217
+ const stale = [];
218
+ const consider = (name, order, folder) => {
219
+ const p = boardPath(name);
220
+ if (!p || !isRegularFile(p)) {
221
+ stale.push(name);
222
+ return null;
223
+ }
224
+ const content = readFileSync(p, "utf8");
225
+ if (b.boards[name] !== hash(content)) {
226
+ stale.push(name);
227
+ return null;
228
+ }
216
229
  let obj;
217
230
  try {
218
- obj = JSON.parse(readFileSync(p, "utf8"));
231
+ obj = JSON.parse(content);
219
232
  } catch {
233
+ return `board "${name}" is not valid JSON - fix the file`;
234
+ }
235
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `board "${name}" is not a JSON object - fix the file`;
236
+ plan.push({
237
+ name,
238
+ path: p,
239
+ obj,
240
+ order,
241
+ folder
242
+ });
243
+ return null;
244
+ };
245
+ const folders = [];
246
+ for (const [i, it] of tree.entries()) {
247
+ if (typeof it === "string") {
248
+ const e = consider(it, i, null);
249
+ if (e) return json(res, 422, { error: e });
220
250
  continue;
221
251
  }
222
- if (!obj || typeof obj !== "object") continue;
223
- obj.order = i;
224
- atomicWrite(p, JSON.stringify(obj, null, 2) + "\n");
252
+ const description = readDescription(it.description);
253
+ folders.push({
254
+ name: it.folder,
255
+ order: i,
256
+ ...description ? { description } : {}
257
+ });
258
+ for (const [j, kid] of it.boards.entries()) {
259
+ const e = consider(kid, j, it.folder);
260
+ if (e) return json(res, 422, { error: e });
261
+ }
262
+ }
263
+ if (stale.length) return json(res, 409, {
264
+ error: "boards changed on disk",
265
+ stale
266
+ });
267
+ const sha256 = {};
268
+ for (const w of plan) {
269
+ if (w.obj.order === w.order && (w.folder ? w.obj.folder === w.folder : w.obj.folder === void 0)) continue;
270
+ w.obj.order = w.order;
271
+ if (w.folder) w.obj.folder = w.folder;
272
+ else delete w.obj.folder;
273
+ const next = JSON.stringify(w.obj, null, 2) + "\n";
274
+ atomicWrite(w.path, next);
275
+ sha256[w.name] = hash(next);
225
276
  }
226
- return json(res, 200, { ok: true });
277
+ mkdirSync(boardsDir, { recursive: true });
278
+ let foldersSha = null;
279
+ if (folders.length) {
280
+ const next = JSON.stringify({
281
+ version: 1,
282
+ folders
283
+ }, null, 2) + "\n";
284
+ atomicWrite(foldersPath, next);
285
+ foldersSha = hash(next);
286
+ } else rmSync(foldersPath, { force: true });
287
+ return json(res, 200, {
288
+ ok: true,
289
+ sha256: {
290
+ boards: sha256,
291
+ folders: foldersSha
292
+ }
293
+ });
227
294
  }
228
295
  const boardMatch = /^boards\/([^/]+)$/.exec(path);
229
296
  if (boardMatch) {
@@ -268,9 +335,11 @@ function apiMiddleware(root, opts = {}) {
268
335
  }
269
336
  mkdirSync(boardsDir, { recursive: true });
270
337
  const incoming = body.board;
271
- if (incoming && typeof incoming === "object" && incoming.order === void 0 && current) try {
272
- const o = JSON.parse(current).order;
273
- if (typeof o === "number" && Number.isFinite(o)) incoming.order = o;
338
+ if (incoming && typeof incoming === "object" && current) try {
339
+ const disk = JSON.parse(current);
340
+ if (incoming.order === void 0 && typeof disk.order === "number" && Number.isFinite(disk.order)) incoming.order = disk.order;
341
+ if (incoming.folder === void 0 && validName(disk.folder)) incoming.folder = disk.folder;
342
+ if (incoming.description === void 0 && readDescription(disk.description)) incoming.description = disk.description;
274
343
  } catch {}
275
344
  const next2 = JSON.stringify(body.board, null, 2) + "\n";
276
345
  atomicWrite(p, next2);
@@ -294,7 +363,7 @@ function apiMiddleware(root, opts = {}) {
294
363
  };
295
364
  const asPng = url.searchParams.get("format") === "png";
296
365
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
297
- const { shootFrame } = await import("./shot-BWhoz6cU.mjs");
366
+ const { shootFrame } = await import("./shot-CwmHO5T4.mjs");
298
367
  const r = await shootFrame({
299
368
  root,
300
369
  viewports: opts.viewports ?? {},
@@ -328,8 +397,8 @@ function apiMiddleware(root, opts = {}) {
328
397
  if (path === "poster" && req.method === "GET") {
329
398
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
330
399
  const src = url.searchParams.get("src") ?? "";
331
- const { ensurePoster, isLocalClip } = await import("./poster-CbpzSzJu.mjs");
332
- const { isLocalAssetRef } = await import("./build-DfuTQZlY.mjs");
400
+ const { ensurePoster, isLocalClip } = await import("./poster-DOY7pax8.mjs");
401
+ const { isLocalAssetRef } = await import("./build-Ct0iMoSi.mjs");
333
402
  if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
334
403
  const r = await ensurePoster(join(root, "design", "assets"), src);
335
404
  if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
@@ -611,7 +680,12 @@ const VIRTUAL_CONFIG = "virtual:sh-config";
611
680
  const VIRTUAL_DATA = "virtual:sh-data";
612
681
  function marverPlugin(ctx) {
613
682
  const { root, clientDir, config } = ctx;
614
- let manifest = scanFrames(root);
683
+ let project = {
684
+ name: config.share.name || basename(root),
685
+ description: config.description
686
+ };
687
+ const projectInfo = () => project;
688
+ let manifest = scanFrames(root, projectInfo());
615
689
  let devServer = null;
616
690
  const bootId = String(process.hrtime.bigint());
617
691
  let invRev = 0;
@@ -752,9 +826,13 @@ function marverPlugin(ctx) {
752
826
  }));
753
827
  server.middlewares.use(routesMiddleware(server, clientDir));
754
828
  devServer = server;
829
+ let framesKey = JSON.stringify(manifest.frames);
755
830
  const regen = debounce(() => {
756
- manifest = scanFrames(root);
757
- if (writeManifest(root, manifest)) server.ws.send("sh:manifest", manifest);
831
+ manifest = scanFrames(root, projectInfo());
832
+ const changed = writeManifest(root, manifest);
833
+ const key = JSON.stringify(manifest.frames);
834
+ if (changed && key !== framesKey) server.ws.send("sh:manifest", manifest);
835
+ framesKey = key;
758
836
  }, 150);
759
837
  const watched = [join(root, "design", "scenes"), join(root, "design", "components")];
760
838
  const inScope = (file) => watched.some((w) => file.startsWith(w));
@@ -791,14 +869,31 @@ function marverPlugin(ctx) {
791
869
  rescanTheme();
792
870
  }
793
871
  });
794
- server.watcher.on("change", (f) => inScope(f) && /\.(tsx|jsx)$/.test(f) && regen());
795
- writeManifest(root, scanFrames(root));
872
+ server.watcher.on("change", (f) => inScope(f) && (/\.(tsx|jsx)$/.test(f) || f.endsWith("_brief.md")) && regen());
873
+ const configFile = join(root, "design", "config.ts");
874
+ server.watcher.on("change", (f) => {
875
+ if (f !== configFile) return;
876
+ loadConfig(root).then((c) => {
877
+ project = {
878
+ name: c.share.name || basename(root),
879
+ description: c.description
880
+ };
881
+ regen();
882
+ }).catch(() => {});
883
+ });
884
+ writeManifest(root, manifest);
796
885
  const boardsDir = join(root, "design", "boards");
797
886
  try {
798
887
  mkdirSync(boardsDir, { recursive: true });
799
888
  const timers = /* @__PURE__ */ new Map();
889
+ let listTimer;
800
890
  const watcher = watch(boardsDir, (_event, file) => {
801
891
  if (!file || !file.endsWith(".json")) return;
892
+ clearTimeout(listTimer);
893
+ listTimer = setTimeout(() => {
894
+ server.ws.send("sh:boards", {});
895
+ regen();
896
+ }, 150);
802
897
  clearTimeout(timers.get(file));
803
898
  timers.set(file, setTimeout(() => {
804
899
  timers.delete(file);
@@ -849,7 +944,7 @@ function marverPlugin(ctx) {
849
944
  });
850
945
  return;
851
946
  }
852
- const { shootFrame } = await import("./shot-BWhoz6cU.mjs");
947
+ const { shootFrame } = await import("./shot-CwmHO5T4.mjs");
853
948
  write(await shootFrame({
854
949
  root,
855
950
  viewports: config.viewports,
@@ -1,4 +1,4 @@
1
- import { capture, findChrome } from "./shot-BWhoz6cU.mjs";
1
+ import { capture, findChrome } from "./shot-CwmHO5T4.mjs";
2
2
  import { constants, copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
@@ -95,8 +95,8 @@ async function shootFrame(opts) {
95
95
  };
96
96
  const plan = planShot(frame, viewports, size);
97
97
  if (frame.kind !== "html") try {
98
- const { scanAssetRefs } = await import("./build-DfuTQZlY.mjs");
99
- const { ensurePoster } = await import("./poster-CbpzSzJu.mjs");
98
+ const { scanAssetRefs } = await import("./build-Ct0iMoSi.mjs");
99
+ const { ensurePoster } = await import("./poster-DOY7pax8.mjs");
100
100
  const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
101
101
  for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
102
102
  } catch {}
package/docs/publish.md CHANGED
@@ -31,7 +31,10 @@ opens the same view on the published site, as long as its board and frame shippe
31
31
 
32
32
  **What ships**: the boards you list, the frames they reference, and the host `public/`
33
33
  directory in full (the `--boards` filter covers frames, not public assets). A flow you
34
- publish must have all its `data-goto` targets on a published board.
34
+ publish must have all its `data-goto` targets on a published board. Sidebar folders ship
35
+ only around published boards (a folder with nothing published never reaches the bundle),
36
+ and `description`s ship only for published things - the project's, the published boards',
37
+ their folders', scenes' and frames'.
35
38
 
36
39
  ## Who can open your canvas
37
40
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
5
5
  "type": "module",
6
6
  "private": false,