@marver-design/marver 0.20.0 → 0.22.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 (45) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +17 -4
  3. package/dist/{bake-tAb0D6Rc.mjs → bake-BSX4XR_U.mjs} +1 -1
  4. package/dist/board-status-CWGHIdo_.mjs +1307 -0
  5. package/dist/boards-BTGNPVMx.mjs +187 -0
  6. package/dist/{build-DwpNPl6Z.mjs → build-Dd2Qm3OG.mjs} +87 -16
  7. package/dist/cli.mjs +67 -8
  8. package/dist/config-DJxMRVD8.mjs +373 -0
  9. package/dist/context-DUAENlJ6.mjs +600 -0
  10. package/dist/{daemon-DbHvLQUL.mjs → daemon-BmkwErpC.mjs} +1 -1
  11. package/dist/{dev-BjdDP69b.mjs → dev-BW7a5kDF.mjs} +8 -6
  12. package/dist/{init-DKxuRxBr.mjs → init-NJ3xjLVu.mjs} +39 -44
  13. package/dist/managed-write-Bo-oPc-i.mjs +71 -0
  14. package/dist/{manifest-B01PSyDc.mjs → manifest-BqBcMJcd.mjs} +26 -380
  15. package/dist/{plugin-y4Ch7o_A.mjs → plugin-8XntSCx_.mjs} +234 -97
  16. package/dist/{poster-FCv_nXyP.mjs → poster-DRZDszTI.mjs} +1 -1
  17. package/dist/{publish-bakes-XH6Bac58.mjs → publish-bakes-CCWRV9iX.mjs} +2 -2
  18. package/dist/{shot-iw3SEcpn.mjs → shot-C22Ues04.mjs} +2 -2
  19. package/docs/boards-and-folders.md +161 -0
  20. package/docs/context.md +117 -0
  21. package/docs/sharing.md +12 -2
  22. package/package.json +1 -1
  23. package/src/client/shell/BoardList.tsx +143 -54
  24. package/src/client/shell/ContextMenu.tsx +16 -5
  25. package/src/client/shell/StatusPicker.tsx +91 -0
  26. package/src/client/shell/board-icons.tsx +75 -0
  27. package/src/client/shell/store.ts +93 -14
  28. package/src/client/shell/styles.css +41 -9
  29. package/src/shared/board-tree.ts +291 -143
  30. package/src/shared/board-types.ts +103 -0
  31. package/src/shared/context.ts +265 -0
  32. package/src/shared/status.ts +157 -0
  33. package/templates/AGENTS-embedded.md +4 -3
  34. package/templates/AGENTS-studio.md +4 -3
  35. package/templates/context/INDEX.md +50 -0
  36. package/templates/context/map.json +6 -0
  37. package/templates/context/shipped-knowledge.md +19 -0
  38. package/templates/context/shipped.md +28 -0
  39. package/templates/instructions/boards.md +78 -22
  40. package/templates/instructions/context.md +114 -0
  41. package/templates/playbooks/publish-canvas/PLAYBOOK.md +72 -0
  42. package/templates/playbooks/reorganize-context/PLAYBOOK.md +232 -0
  43. package/templates/playbooks/reorganize-context/eval.md +93 -0
  44. package/dist/boards-BmxcT3Lc.mjs +0 -290
  45. package/dist/boards-PuVzw5Wp.mjs +0 -62
@@ -1,8 +1,9 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { a as ROUTE, i as PKG, r as NAME } from "./cli.mjs";
3
- import { a as listBoardFiles, c as BOARD_NAME, g as hash, h as validateWire, i as isRegularFile, l as FOLDERS_FILE, m as readTitle, n as checkBoardsDir, o as nodeExists, p as readDescription, s as readRegistry, t as boardFields } from "./boards-BmxcT3Lc.mjs";
3
+ import { A as isRegularFile, B as folderMap, D as boardFields, F as BOARD_NAME, G as validateWire, I as FOLDERS_FILE, K as wireKids, L as buildTree, M as nodeExists, N as readRegistry, O as checkBoardsDir, P as withRegistryLock, T as AUTHOR_FIELDS, U as readDescription, W as readTitle, Y as HAS_STATUS, at as hash, et as readReason, it as settableStatuses, j as listBoardFiles, n as readContextFacts, nt as readType, rt as resolveType, t as annotateBoards, tt as readStatusWord } from "./board-status-CWGHIdo_.mjs";
4
4
  import { n as localProfile, t as isConnected } from "./profile-BjAPAJSb.mjs";
5
- import { c as loadConfig, i as setSceneTitle, n as isNoteFile, o as writeManifest, r as scanFrames, t as affectedFrameIds } from "./manifest-B01PSyDc.mjs";
5
+ import { i as setSceneTitle, n as isNoteFile, o as writeManifest, r as scanFrames, t as affectedFrameIds } from "./manifest-BqBcMJcd.mjs";
6
+ import { n as loadConfig } from "./config-DJxMRVD8.mjs";
6
7
  import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
7
8
  import { basename, dirname, join, resolve, sep } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
@@ -128,11 +129,88 @@ function apiMiddleware(root, opts = {}) {
128
129
  const de = dirError();
129
130
  if (de) return json(res, 422, { error: de });
130
131
  mkdirSync(boardsDir, { recursive: true });
131
- return json(res, 200, listBoardFiles(boardsDir).boards.map((b) => ({
132
+ const files = listBoardFiles(boardsDir).boards;
133
+ const rows = files.map((b) => ({
132
134
  name: b.name,
133
- sha256: b.sha256,
134
135
  ...boardFields(b.json, validName)
135
- })));
136
+ }));
137
+ const reg = readRegistry(boardsDir);
138
+ const regFolders = reg.state === "ok" ? reg.folders : [];
139
+ const tree = buildTree(rows, regFolders);
140
+ const fm = folderMap(tree);
141
+ const facts = readContextFacts(root);
142
+ const notes = annotateBoards(root, files, regFolders, (n) => fm.get(n) ?? null, facts);
143
+ const settable = settableStatuses(facts.present);
144
+ return json(res, 200, files.map((b, i) => {
145
+ const n = notes.get(b.name);
146
+ return {
147
+ ...rows[i],
148
+ sha256: b.sha256,
149
+ ...n ?? {},
150
+ ...n?.status ? { settable } : {}
151
+ };
152
+ }));
153
+ }
154
+ if (path === "boards/status" && req.method === "POST") {
155
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
156
+ const raw = await readBody(req);
157
+ if (raw == null) return json(res, 400, { error: "body too large or unreadable" });
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(raw);
161
+ } catch {
162
+ return json(res, 400, { error: "malformed JSON" });
163
+ }
164
+ if (!parsed || typeof parsed !== "object") return json(res, 400, { error: "expected an object" });
165
+ const { name, status: statusRaw, reason: reasonRaw, baseHash } = parsed;
166
+ if (!validName(name) || name === "all-scenes") return json(res, 400, { error: "invalid board name" });
167
+ if (baseHash !== void 0 && typeof baseHash !== "string") return json(res, 400, { error: "invalid baseHash" });
168
+ const status = statusRaw === null ? null : readStatusWord(statusRaw);
169
+ if (status === void 0) return json(res, 400, { error: "invalid status" });
170
+ const reason = readReason(reasonRaw);
171
+ if (status === "blocked" && !reason) return json(res, 400, { error: "a blocked board says why - give a reason" });
172
+ {
173
+ const de = dirError();
174
+ if (de) return json(res, 400, { error: de });
175
+ }
176
+ const file = boardPath(name);
177
+ if (!file) return json(res, 400, { error: "invalid board name" });
178
+ if (!existsSync(file)) return json(res, 404, { error: `board "${name}" does not exist` });
179
+ if (!notSymlink(file)) return json(res, 400, { error: "refusing to write a symlinked board file" });
180
+ const current = readFileSync(file, "utf8");
181
+ if (baseHash !== void 0 && baseHash !== hash(current)) return json(res, 409, {
182
+ error: "board changed on disk",
183
+ sha256: hash(current)
184
+ });
185
+ let obj;
186
+ try {
187
+ obj = JSON.parse(current);
188
+ } catch {
189
+ return json(res, 422, { error: `board "${name}" is not valid JSON - fix the file` });
190
+ }
191
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return json(res, 422, { error: `board "${name}" is not a JSON object - fix the file` });
192
+ const reg = readRegistry(boardsDir);
193
+ const regFolders = reg.state === "ok" ? reg.folders : [];
194
+ const all = listBoardFiles(boardsDir).boards;
195
+ const fm = folderMap(buildTree(all.map((b) => ({
196
+ name: b.name,
197
+ ...boardFields(b.json, validName)
198
+ })), regFolders));
199
+ const folder = regFolders.find((f) => f.name === fm.get(name));
200
+ const parent = folder?.parent ? regFolders.find((f) => f.name === folder.parent) : void 0;
201
+ const type = resolveType(obj.type, folder?.type, parent?.type);
202
+ if (!HAS_STATUS.includes(type)) return json(res, 422, { error: `a ${type} board carries no status - only feature and project boards do` });
203
+ if (status !== null && !settableStatuses(readContextFacts(root).present).includes(status)) return json(res, 422, { error: status === "done" ? "Done is never set by hand - it comes from context/shipped.md" : `with context/, ${status} is read from the evidence - a plan, a contract, the shipped record` });
204
+ if (status === null) delete obj.status;
205
+ else obj.status = status;
206
+ if (status === "blocked") obj.reason = reason;
207
+ else delete obj.reason;
208
+ const next = JSON.stringify(obj, null, 2) + "\n";
209
+ if (next !== current) atomicWrite(file, next);
210
+ return json(res, 200, {
211
+ name,
212
+ sha256: hash(next)
213
+ });
136
214
  }
137
215
  if (path === "folders" && req.method === "GET") {
138
216
  const de = dirError();
@@ -259,96 +337,144 @@ function apiMiddleware(root, opts = {}) {
259
337
  const de = dirError();
260
338
  if (de) return json(res, 400, { error: de });
261
339
  }
262
- const reg = readRegistry(boardsDir);
263
- if (reg.state === "malformed") return json(res, 422, { error: reg.error });
264
- if (reg.sha256 !== b.folders) return json(res, 409, {
265
- error: "folders changed on disk",
266
- stale: [FOLDERS_FILE]
340
+ const reply = (status, body) => ({
341
+ status,
342
+ body
267
343
  });
268
- const unseen = listBoardFiles(boardsDir).boards.map((x) => x.name).filter((n) => !(n in b.boards));
269
- if (unseen.length) return json(res, 409, {
270
- error: "boards changed on disk",
271
- stale: unseen
272
- });
273
- const plan = [];
274
- const stale = [];
275
- const consider = (name, order, folder) => {
276
- const p = boardPath(name);
277
- if (!p || !isRegularFile(p)) {
278
- stale.push(name);
279
- return null;
280
- }
281
- const content = readFileSync(p, "utf8");
282
- if (b.boards[name] !== hash(content)) {
283
- stale.push(name);
344
+ mkdirSync(boardsDir, { recursive: true });
345
+ const locked = withRegistryLock(boardsDir, 0, () => {
346
+ const reg = readRegistry(boardsDir);
347
+ if (reg.state === "malformed") return reply(422, { error: reg.error });
348
+ if (reg.state === "ok" && reg.folders.some((f) => f.parent) && parsed.protocol !== 2) return reply(422, { error: "this tab predates folders in folders - reload the canvas, then try again" });
349
+ if (reg.sha256 !== b.folders) return reply(409, {
350
+ error: "folders changed on disk",
351
+ stale: [FOLDERS_FILE]
352
+ });
353
+ const unseen = listBoardFiles(boardsDir).boards.map((x) => x.name).filter((n) => !(n in b.boards));
354
+ if (unseen.length) return reply(409, {
355
+ error: "boards changed on disk",
356
+ stale: unseen
357
+ });
358
+ const plan = [];
359
+ const stale = [];
360
+ const consider = (name, order, folder) => {
361
+ const p = boardPath(name);
362
+ if (!p || !isRegularFile(p)) {
363
+ stale.push(name);
364
+ return null;
365
+ }
366
+ const content = readFileSync(p, "utf8");
367
+ if (b.boards[name] !== hash(content)) {
368
+ stale.push(name);
369
+ return null;
370
+ }
371
+ let obj;
372
+ try {
373
+ obj = JSON.parse(content);
374
+ } catch {
375
+ return `board "${name}" is not valid JSON - fix the file`;
376
+ }
377
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `board "${name}" is not a JSON object - fix the file`;
378
+ plan.push({
379
+ name,
380
+ path: p,
381
+ obj,
382
+ order,
383
+ folder
384
+ });
284
385
  return null;
285
- }
286
- let obj;
386
+ };
387
+ const onDisk = /* @__PURE__ */ new Map();
287
388
  try {
288
- obj = JSON.parse(content);
289
- } catch {
290
- return `board "${name}" is not valid JSON - fix the file`;
389
+ const raw = JSON.parse(readFileSync(join(boardsDir, FOLDERS_FILE), "utf8"));
390
+ if (Array.isArray(raw.folders)) {
391
+ for (const r of raw.folders) if (r && typeof r === "object" && typeof r.name === "string") onDisk.set(r.name, r);
392
+ }
393
+ } catch {}
394
+ const MANAGED = /* @__PURE__ */ new Set([
395
+ "name",
396
+ "order",
397
+ "parent",
398
+ "title",
399
+ "description",
400
+ "type"
401
+ ]);
402
+ const folders = [];
403
+ const walk = (list, parent) => {
404
+ for (const [i, it] of list.entries()) {
405
+ if (typeof it === "string") {
406
+ const e = consider(it, i, parent);
407
+ if (e) return e;
408
+ continue;
409
+ }
410
+ const title = readTitle(it.title), description = readDescription(it.description);
411
+ const disk = onDisk.get(it.folder);
412
+ const type = readType(it.type) ?? readType(disk?.type);
413
+ const kept = disk ? Object.fromEntries(Object.entries(disk).filter(([k]) => !MANAGED.has(k))) : {};
414
+ folders.push({
415
+ ...kept,
416
+ name: it.folder,
417
+ order: i,
418
+ ...parent ? { parent } : {},
419
+ ...title ? { title } : {},
420
+ ...description ? { description } : {},
421
+ ...type ? { type } : {}
422
+ });
423
+ const e = walk(wireKids(it), it.folder);
424
+ if (e) return e;
425
+ }
426
+ return null;
427
+ };
428
+ {
429
+ const e = walk(tree, null);
430
+ if (e) return reply(422, { error: e });
291
431
  }
292
- if (!obj || typeof obj !== "object" || Array.isArray(obj)) return `board "${name}" is not a JSON object - fix the file`;
293
- plan.push({
294
- name,
295
- path: p,
296
- obj,
297
- order,
298
- folder
432
+ if (stale.length) return reply(409, {
433
+ error: "boards changed on disk",
434
+ stale
299
435
  });
300
- return null;
301
- };
302
- const folders = [];
303
- for (const [i, it] of tree.entries()) {
304
- if (typeof it === "string") {
305
- const e = consider(it, i, null);
306
- if (e) return json(res, 422, { error: e });
307
- continue;
436
+ const sha256 = {};
437
+ for (const w of plan) {
438
+ if (w.obj.order === w.order && (w.folder ? w.obj.folder === w.folder : w.obj.folder === void 0)) continue;
439
+ if (hash(readFileSync(w.path, "utf8")) !== b.boards[w.name]) return reply(409, {
440
+ error: "boards changed on disk",
441
+ stale: [w.name]
442
+ });
443
+ w.obj.order = w.order;
444
+ if (w.folder) w.obj.folder = w.folder;
445
+ else delete w.obj.folder;
446
+ const next = JSON.stringify(w.obj, null, 2) + "\n";
447
+ atomicWrite(w.path, next);
448
+ sha256[w.name] = hash(next);
308
449
  }
309
- const title = readTitle(it.title), description = readDescription(it.description);
310
- folders.push({
311
- name: it.folder,
312
- order: i,
313
- ...title ? { title } : {},
314
- ...description ? { description } : {}
450
+ mkdirSync(boardsDir, { recursive: true });
451
+ if ((nodeExists(foldersPath) ? hash(readFileSync(foldersPath, "utf8")) : null) !== b.folders) return reply(409, {
452
+ error: "folders changed on disk",
453
+ stale: [FOLDERS_FILE]
454
+ });
455
+ let foldersSha = null;
456
+ if (folders.length) {
457
+ const version = folders.some((f) => f.parent) ? 2 : 1;
458
+ const next = JSON.stringify({
459
+ version,
460
+ folders
461
+ }, null, 2) + "\n";
462
+ atomicWrite(foldersPath, next);
463
+ foldersSha = hash(next);
464
+ } else rmSync(foldersPath, { force: true });
465
+ return reply(200, {
466
+ ok: true,
467
+ sha256: {
468
+ boards: sha256,
469
+ folders: foldersSha
470
+ }
315
471
  });
316
- for (const [j, kid] of it.boards.entries()) {
317
- const e = consider(kid, j, it.folder);
318
- if (e) return json(res, 422, { error: e });
319
- }
320
- }
321
- if (stale.length) return json(res, 409, {
322
- error: "boards changed on disk",
323
- stale
324
472
  });
325
- const sha256 = {};
326
- for (const w of plan) {
327
- if (w.obj.order === w.order && (w.folder ? w.obj.folder === w.folder : w.obj.folder === void 0)) continue;
328
- w.obj.order = w.order;
329
- if (w.folder) w.obj.folder = w.folder;
330
- else delete w.obj.folder;
331
- const next = JSON.stringify(w.obj, null, 2) + "\n";
332
- atomicWrite(w.path, next);
333
- sha256[w.name] = hash(next);
334
- }
335
- mkdirSync(boardsDir, { recursive: true });
336
- let foldersSha = null;
337
- if (folders.length) {
338
- const next = JSON.stringify({
339
- version: 1,
340
- folders
341
- }, null, 2) + "\n";
342
- atomicWrite(foldersPath, next);
343
- foldersSha = hash(next);
344
- } else rmSync(foldersPath, { force: true });
345
- return json(res, 200, {
346
- ok: true,
347
- sha256: {
348
- boards: sha256,
349
- folders: foldersSha
350
- }
473
+ if (!locked) return json(res, 409, {
474
+ error: "folders changed on disk",
475
+ stale: [FOLDERS_FILE]
351
476
  });
477
+ return json(res, locked.status, locked.body);
352
478
  }
353
479
  const boardMatch = /^boards\/([^/]+)$/.exec(path);
354
480
  if (boardMatch) {
@@ -395,10 +521,8 @@ function apiMiddleware(root, opts = {}) {
395
521
  const incoming = body.board;
396
522
  if (incoming && typeof incoming === "object" && current) try {
397
523
  const disk = JSON.parse(current);
398
- if (incoming.order === void 0 && typeof disk.order === "number" && Number.isFinite(disk.order)) incoming.order = disk.order;
399
- if (incoming.folder === void 0 && validName(disk.folder)) incoming.folder = disk.folder;
400
- if (incoming.title === void 0 && readTitle(disk.title)) incoming.title = disk.title;
401
- if (incoming.description === void 0 && readDescription(disk.description)) incoming.description = disk.description;
524
+ const valid = boardFields(disk, validName);
525
+ for (const k of AUTHOR_FIELDS) if (incoming[k] === void 0 && valid[k] !== void 0) incoming[k] = disk[k];
402
526
  } catch {}
403
527
  const next2 = JSON.stringify(body.board, null, 2) + "\n";
404
528
  atomicWrite(p, next2);
@@ -422,7 +546,7 @@ function apiMiddleware(root, opts = {}) {
422
546
  };
423
547
  const asPng = url.searchParams.get("format") === "png";
424
548
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
425
- const { shootFrame } = await import("./shot-iw3SEcpn.mjs");
549
+ const { shootFrame } = await import("./shot-C22Ues04.mjs");
426
550
  const r = await shootFrame({
427
551
  root,
428
552
  viewports: opts.viewports ?? {},
@@ -472,7 +596,7 @@ function apiMiddleware(root, opts = {}) {
472
596
  if (typeof theme !== "string" || !/^[a-z0-9-]+$/i.test(theme)) return json(res, 400, { error: "invalid theme" });
473
597
  const scale = body.scale === void 0 ? void 0 : body.scale;
474
598
  if (scale !== void 0 && !(Number.isInteger(scale) && scale >= 1 && scale <= 4)) return json(res, 400, { error: "invalid scale" });
475
- const { resolveFrames, shootBatch } = await import("./shot-iw3SEcpn.mjs");
599
+ const { resolveFrames, shootBatch } = await import("./shot-C22Ues04.mjs");
476
600
  const sel = resolveFrames(root, body);
477
601
  if (!sel.ok) return json(res, sel.status, { error: sel.error });
478
602
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
@@ -500,7 +624,7 @@ function apiMiddleware(root, opts = {}) {
500
624
  return json(res, 400, { error: "malformed JSON" });
501
625
  }
502
626
  if (!body || typeof body !== "object" || !Array.isArray(body.asks) || !body.asks.length || body.asks.length > 200) return json(res, 400, { error: "asks must list 1-200 frames" });
503
- const { bakeBatch, ASK_MAX } = await import("./bake-tAb0D6Rc.mjs");
627
+ const { bakeBatch, ASK_MAX } = await import("./bake-BSX4XR_U.mjs");
504
628
  let manifest = {};
505
629
  try {
506
630
  manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
@@ -544,8 +668,8 @@ function apiMiddleware(root, opts = {}) {
544
668
  if (path === "poster" && req.method === "GET") {
545
669
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
546
670
  const src = url.searchParams.get("src") ?? "";
547
- const { ensurePoster, isLocalClip } = await import("./poster-FCv_nXyP.mjs");
548
- const { isLocalAssetRef } = await import("./build-DwpNPl6Z.mjs");
671
+ const { ensurePoster, isLocalClip } = await import("./poster-DRZDszTI.mjs");
672
+ const { isLocalAssetRef } = await import("./build-Dd2Qm3OG.mjs");
549
673
  if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
550
674
  const r = await ensurePoster(join(root, "design", "assets"), src);
551
675
  if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
@@ -1027,7 +1151,7 @@ function marverPlugin(ctx) {
1027
1151
  };
1028
1152
  const prune = () => setTimeout(async () => {
1029
1153
  try {
1030
- (await import("./bake-tAb0D6Rc.mjs")).pruneBakes(root, bakeGen);
1154
+ (await import("./bake-BSX4XR_U.mjs")).pruneBakes(root, bakeGen);
1031
1155
  } catch {}
1032
1156
  }, 500);
1033
1157
  prune();
@@ -1046,6 +1170,19 @@ function marverPlugin(ctx) {
1046
1170
  rescanTheme();
1047
1171
  }
1048
1172
  });
1173
+ const contextDir = join(root, "context") + sep;
1174
+ let statusTimer;
1175
+ const restatus = (f) => {
1176
+ if (!f.startsWith(contextDir) && !(inScope(f) && f.endsWith("_brief.md"))) return;
1177
+ clearTimeout(statusTimer);
1178
+ statusTimer = setTimeout(() => {
1179
+ server.ws.send("sh:boards", {});
1180
+ regen();
1181
+ }, 150);
1182
+ };
1183
+ server.watcher.on("add", restatus);
1184
+ server.watcher.on("unlink", restatus);
1185
+ server.watcher.on("change", restatus);
1049
1186
  server.watcher.on("change", (f) => inScope(f) && (/\.(tsx|jsx)$/.test(f) || f.endsWith("_brief.md") || isNoteFile(f)) && regen());
1050
1187
  const configFile = join(root, "design", "config.ts");
1051
1188
  server.watcher.on("change", (f) => {
@@ -1166,7 +1303,7 @@ function marverPlugin(ctx) {
1166
1303
  });
1167
1304
  return;
1168
1305
  }
1169
- const { shootFrame, shootBatch, resolveFrames } = await import("./shot-iw3SEcpn.mjs");
1306
+ const { shootFrame, shootBatch, resolveFrames } = await import("./shot-C22Ues04.mjs");
1170
1307
  if (ways[0] === "frame") {
1171
1308
  if (typeof spec.frame !== "string") {
1172
1309
  write({
@@ -1,4 +1,4 @@
1
- import { capture, t as findChrome } from "./shot-iw3SEcpn.mjs";
1
+ import { capture, t as findChrome } from "./shot-C22Ues04.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";
@@ -1,6 +1,6 @@
1
1
  import { o as slideSize } from "./cli.mjs";
2
- import { planShot, t as findChrome } from "./shot-iw3SEcpn.mjs";
3
- import { ASK_MAX, bakeBatch } from "./bake-tAb0D6Rc.mjs";
2
+ import { planShot, t as findChrome } from "./shot-C22Ues04.mjs";
3
+ import { ASK_MAX, bakeBatch } from "./bake-BSX4XR_U.mjs";
4
4
  import { MIME } from "./serve-z5qtj_wJ.mjs";
5
5
  import { copyFileSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import { extname, isAbsolute, join, relative, resolve } from "node:path";
@@ -337,8 +337,8 @@ async function shootFrame(opts) {
337
337
  };
338
338
  const plan = planShot(frame, viewports, size);
339
339
  if (frame.kind !== "html") try {
340
- const { scanAssetRefs } = await import("./build-DwpNPl6Z.mjs");
341
- const { ensurePoster } = await import("./poster-FCv_nXyP.mjs");
340
+ const { scanAssetRefs } = await import("./build-Dd2Qm3OG.mjs");
341
+ const { ensurePoster } = await import("./poster-DRZDszTI.mjs");
342
342
  const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
343
343
  for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
344
344
  } catch {}
@@ -0,0 +1,161 @@
1
+ # Boards and folders
2
+
3
+ A board is a saved canvas - a set of frames, arranged. Boards sit at the top of the sidebar, and
4
+ folders keep them tidy: a folder holds boards and folders, a folder inside a folder - a
5
+ **sub-folder** - holds boards. Two levels, so a board sits at the root, in a folder, or in a
6
+ sub-folder.
7
+
8
+ ```
9
+ Start here
10
+ Features
11
+ Shipper <- a sub-folder
12
+ New load
13
+ Orders
14
+ Phone
15
+ Pickup inspection
16
+ Carrier office <- a board, straight in Features
17
+ Context
18
+ Meetings
19
+ Archive
20
+ ```
21
+
22
+ Everything below works the same way at both levels, and the same from the sidebar as from the files
23
+ - so a person arranging boards by hand and an agent arranging them by writing files always agree.
24
+
25
+ ## In the sidebar
26
+
27
+ - **New folder** - right-click the Boards header, or its `+`. Name it inline: what you type is its
28
+ title, any casing, punctuation or emoji.
29
+ - **New folder inside** - right-click a top-level folder: a sub-folder, at its end.
30
+ - **Drag** a board into any folder or sub-folder (drop it on the header), or between any two rows -
31
+ the blue seam shows exactly where a release lands, indented with the level it lands in. Drag a
32
+ folder among the boards; a folder with no sub-folders can also drop into a top-level folder and
33
+ become one.
34
+ - **Move to new folder** on a board makes a folder at the board's own level, with the board inside:
35
+ in its slot at the root, a sub-folder in its slot when it sits in a folder, and right after its
36
+ sub-folder when it sits in one (a sub-folder holds no folders).
37
+ - **Move to top level** - on a board in a folder, or on a sub-folder.
38
+ - **Rename** changes the title. **Delete folder** never deletes a board: what the folder held moves
39
+ up one level, into its place.
40
+
41
+ At the bottom of a folder, one gap belongs to several levels: after the last board of a sub-folder
42
+ you can drop into the sub-folder, after it in its folder, or after the folder at the root. Where you
43
+ hold the pointer decides - over the row above, its indent picks the level; over the row below, the
44
+ seam takes that row's level.
45
+
46
+ ## In the files
47
+
48
+ Two files carry it, and they are the truth.
49
+
50
+ **A board names its folder** - the one it sits in directly, at either level - next to its rank:
51
+
52
+ ```json
53
+ { "version": 1, "name": "new-load", "folder": "shipper", "order": 0, "nodes": [] }
54
+ ```
55
+
56
+ `order` ranks the board among its siblings. At every level the boards and folders there share one
57
+ sequence: the root's boards and folders, a folder's boards and sub-folders, a sub-folder's boards.
58
+
59
+ **`design/boards/_folders.json` lists the folders**, ranks them, titles and describes them - and
60
+ nests a sub-folder with `parent`:
61
+
62
+ ```json
63
+ { "version": 2, "folders": [
64
+ { "name": "features", "order": 1, "title": "Features", "description": "One board per capability, by surface" },
65
+ { "name": "shipper", "parent": "features", "order": 0, "title": "Shipper" },
66
+ { "name": "context", "order": 2 } ] }
67
+ ```
68
+
69
+ - A folder's `name` is its slug - what boards point at with `folder`, unique across both levels. Its
70
+ `title` is what people see; renaming in the sidebar changes the title and never the slug.
71
+ - `parent` names a top-level folder. A sub-folder never holds a folder.
72
+ - The file says `"version": 2` while any folder has a parent, and `1` when none does.
73
+ - A folder a board names but the registry lacks is still real - it shows at the top level. To nest
74
+ it, register it with its `parent`.
75
+ - A registry that breaks these rules is an error the canvas shows, never read as empty.
76
+
77
+ `npx marver boards` prints the tree as the files say it is - every folder and sub-folder, every board
78
+ with its rank, the landing board - and `--json` gives the same tree. The landing board is the first
79
+ board in that reading order, down through folders.
80
+
81
+ ## Descriptions
82
+
83
+ Every folder, board, scene and frame takes one `description`: a sentence for agents - what it is
84
+ for, and its state when that is not obvious. A folder's sits on its entry in `_folders.json`. They
85
+ all land in `design/manifest.json`, the file an agent reads first, so a new session knows what each
86
+ folder is for before it files a single board.
87
+
88
+ ## Types
89
+
90
+ Every board wears an icon for what it is for, the same on every canvas:
91
+
92
+ | Type | For |
93
+ |---|---|
94
+ | `start` | the way in: the index, the shipped record, the timeline |
95
+ | `feature` | one capability: its spec, lo-fi and hi-fi |
96
+ | `surface` | the whole product to walk, from frames on feature boards |
97
+ | `project` | a deliverable or a question |
98
+ | `feedback` | one frame per theme |
99
+ | `context` | what came in from outside: meetings, threads, competitors |
100
+ | `deck` | slides |
101
+ | `archive` | snapshots and retired work |
102
+
103
+ A board states `"type"` in its JSON, or wears the type of the folder it sits in - `"type"` on the
104
+ folder's entry in `_folders.json` - or of that folder's parent; else it is plain. Moving a board
105
+ changes an inherited type, never one the board states. The type never decides how a board publishes,
106
+ but `marver build` suggests a publish type from it - `slides` for a deck, `refs` for a context board,
107
+ `doc` for a project - and the publish row decides.
108
+
109
+ ## Status
110
+
111
+ Feature and project boards also wear a status, read from the project's `context/` - Backlog, To do,
112
+ In progress (filling by phase), Done, Done reported, Unknown - or decided on the board itself:
113
+ `"status": "archived"`, `"paused"`, or `"blocked"` with a `"reason"`. **Done is never set by hand.**
114
+ The tooltip says what decided it. [Context](context.md) has the rules.
115
+
116
+ **Set it from the sidebar:** right-click a feature or project board, **Change status…**. The picker
117
+ shows what the evidence says and offers only what a person decides - Blocked (it asks why), Paused,
118
+ Archived, and **Back to the evidence** to undo a decision; without `context/`, Backlog, To do and In
119
+ progress as well. Type to filter, or press a number. It writes `status` and `reason` into the board
120
+ file and nothing else.
121
+
122
+ One rule draws them: a status still open is an outline in its colour - grey, yellow filling by phase,
123
+ red for blocked - and a settled one is filled: Done a green disc, Archived a solid brown box. Done,
124
+ reported is the green outline - a written claim, not yet confirmed.
125
+
126
+ ## Starting points
127
+
128
+ - `npx marver init --kind product|knowledge` - on a fresh canvas, the typed folders every canvas
129
+ shares: Start here, Features and Surfaces (a product) or Projects (knowledge work), Feedback,
130
+ Context, Archive. Without the flag a fresh canvas gets product folders when an app is detected,
131
+ knowledge otherwise, and says which. On an existing canvas only `--kind` adds them, and only the
132
+ missing ones - init never renames or moves a folder.
133
+ - `npx marver folders add decks` - one more typed folder later: `start`, `features`, `surfaces`,
134
+ `projects`, `feedback`, `context`, `decks`, `archive`.
135
+ - `npx marver boards new <name> --folder features` - a board in its type's starting layout: a
136
+ feature's three phase scenes as three bands (spec, lo-fi, hi-fi), a start board rendering
137
+ `context/INDEX.md` and `context/shipped.md`, a deck on its title slide. `--type` overrides the
138
+ folder's; `--title`, `--description`, `--capability` fill the rest.
139
+
140
+ ## Agents
141
+
142
+ Agents make the same moves by editing those two files (`npx marver boards` prints the tree with each
143
+ board's type and status); `instructions/boards.md` in your project
144
+ teaches each one - creating a sub-folder, moving a folder in or out, renaming a slug (members'
145
+ `folder` and sub-folders' `parent` move with it), deleting (contents up one level). The sidebar
146
+ refuses a write that would overwrite an edit it has not seen, so an agent's file write and a
147
+ person's drag never silently erase each other.
148
+
149
+ ## Publishing
150
+
151
+ A published canvas shows the folders of the published boards only - a sub-folder's parent included,
152
+ since the tree needs it. A folder with nothing published at any depth never reaches the bundle, so
153
+ its name and description stay private. Board types ship; statuses do not - a published board loses its
154
+ `status`, `reason` and `capability`, and shows a status only where its publish row says
155
+ `"showStatus": true` and the evidence behind it is `publishable` - never a blocked reason.
156
+
157
+ ## Mixed versions
158
+
159
+ Marver 0.20 and earlier read one level of folders. They refuse a version-2 registry with an error
160
+ instead of rewriting it flat, so upgrade the whole team before nesting a folder. A browser tab opened
161
+ before the upgrade is refused the same way once folders nest - reload it.