@marver-design/marver 0.20.0 → 0.21.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  Notable changes to `@marver-design/marver`. Format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver.
4
4
 
5
+ ## 0.21.0 - 2026-10-05
6
+
7
+ ### Added
8
+
9
+ - **Folders in folders.** A folder can now hold folders, one level down, so the sidebar has two
10
+ levels: a board sits at the root, in a folder, or in a sub-folder. Every folder move works at
11
+ both levels, from the sidebar and from the files: **New folder inside** on a top-level folder's
12
+ menu, **Move to top level** for a sub-folder, boards dragged into a sub-folder or between its
13
+ boards, a folder without sub-folders dragged into a top-level folder. Deleting a folder moves
14
+ what it held up one level, into its place - never a board lost. **Move to new folder** now
15
+ makes the folder at the board's own level: a sub-folder when the board sits in a folder.
16
+ - The files carry it with one new field: a sub-folder's entry in `design/boards/_folders.json`
17
+ names its `"parent"`. A board's `folder` stays the one folder it sits in directly. `npx marver
18
+ boards` prints the nested tree, the manifest lists each folder's `parent`, and a published
19
+ canvas keeps the nesting of its published boards (a folder with nothing published at any depth
20
+ stays out of the bundle). `instructions/boards.md` teaches agents the moves.
21
+
22
+ ### Upgrading
23
+
24
+ - **The registry says `"version": 2` while any folder nests, and 1 when none does.** Marver 0.20
25
+ and earlier refuse a version-2 registry with an error instead of rewriting it flat, so a
26
+ teammate on an older version sees a clear message - upgrade the whole team before nesting.
27
+ A canvas tab opened before the upgrade is refused the same way once folders nest: reload it.
28
+ - Run `npx marver init` to take the new folders guidance (unedited instruction files update in
29
+ place; edited ones are staged in `design/.local/latest/`).
30
+
5
31
  ## 0.20.0 - 2026-10-01
6
32
 
7
33
  ### Changed
@@ -1,4 +1,4 @@
1
- import { AREA, SURFACE, pool, shotConcurrency, withBrowser } from "./shot-iw3SEcpn.mjs";
1
+ import { AREA, SURFACE, pool, shotConcurrency, withBrowser } from "./shot-DswS4iRK.mjs";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import { join, sep } from "node:path";
4
4
  import { createHash } from "node:crypto";
@@ -9,9 +9,11 @@ const hash = (s) => createHash("sha256").update(s).digest("hex");
9
9
  /**
10
10
  * Board folders - the pure tree shared by the sidebar, the dev API, the build and the
11
11
  * tests. Files are the truth: a board says which folder it sits in (`folder` on the
12
- * board file, ranked among its siblings by `order`), and `design/boards/_folders.json`
13
- * says which folders exist and where they rank at the root. One level only: folders
14
- * hold boards, never folders. `all-scenes` never enters the tree - callers pin it last.
12
+ * board file - one slug, the folder it sits in directly), ranked among its siblings by
13
+ * `order`, and `design/boards/_folders.json` says which folders exist, where they rank, and
14
+ * which folder holds which (`parent`). Two levels: a folder holds boards and folders, a
15
+ * folder inside a folder (a sub-folder) holds boards only. `all-scenes` never enters the
16
+ * tree - callers pin it last.
15
17
  */
16
18
  /** The on-disk name grammar shared by boards and folders (a board name is a filename). */
17
19
  const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
@@ -36,12 +38,13 @@ const folderExtras = (it) => ({
36
38
  ...it.description ? { description: it.description } : {}
37
39
  });
38
40
  /** The registry file's shape. Returns the rows, or a string naming what is wrong - a
39
- * malformed registry is an ERROR the human must fix (silently reading it as empty would
40
- * let the next drag overwrite their folders), while a missing file is simply no folders. */
41
+ * malformed registry is an ERROR the human must fix (silently reading it as empty, or
42
+ * flattening a broken nesting, would let the next drag overwrite their folders), while a
43
+ * missing file is simply no folders. */
41
44
  function parseFolders(raw) {
42
45
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "expected an object";
43
46
  const { version, folders } = raw;
44
- if (version !== void 0 && version !== 1) return `unsupported version ${String(version)}`;
47
+ if (version !== void 0 && version !== 1 && version !== 2) return `unsupported version ${String(version)}`;
45
48
  if (!Array.isArray(folders)) return "expected a \"folders\" array";
46
49
  const out = [];
47
50
  const seen = /* @__PURE__ */ new Set();
@@ -51,30 +54,45 @@ function parseFolders(raw) {
51
54
  if (seen.has(name)) return `folder "${name}" is listed twice`;
52
55
  seen.add(name);
53
56
  const o = f.order;
57
+ const p = f.parent;
58
+ if (p !== void 0 && !isBoardName(p)) return `folder "${name}" has an invalid parent - a folder name`;
54
59
  const t = readTitle(f.title);
55
60
  const d = readDescription(f.description);
56
61
  out.push({
57
62
  name,
58
63
  ...typeof o === "number" && Number.isFinite(o) ? { order: o } : {},
64
+ ...p !== void 0 ? { parent: p } : {},
59
65
  ...t ? { title: t } : {},
60
66
  ...d ? { description: d } : {}
61
67
  });
62
68
  }
69
+ const byName = new Map(out.map((f) => [f.name, f]));
70
+ for (const f of out) {
71
+ if (f.parent === void 0) continue;
72
+ if (version !== 2) return `folder "${f.name}" names a parent - nested folders need "version": 2`;
73
+ if (f.parent === f.name) return `folder "${f.name}" cannot be its own parent`;
74
+ const p = byName.get(f.parent);
75
+ if (!p) return `folder "${f.name}" names an unknown parent "${f.parent}"`;
76
+ if (p.parent !== void 0) return `folder "${f.name}" would sit three levels deep - folders nest one level only`;
77
+ }
63
78
  return out;
64
79
  }
65
- /** Sidebar order from the files. Root: boards with no folder + every folder (registered
66
- * or implied by a board), ranked by `order` then kind (board before folder) then name.
67
- * Inside a folder: its boards by `order` then name. Unranked sorts after ranked. A folder's
68
- * title and description ride on its item (they live in the registry a tree write rewrites);
69
- * a board's title stays with its row - it lives in the board's own file. */
80
+ /** Sidebar order from the files. At every level the boards and folders there share one
81
+ * sequence, ranked by `order`, then kind (board before folder), then name; unranked sorts
82
+ * after ranked. The root holds root boards and top-level folders (registered without a
83
+ * parent, or implied by a board that names an unregistered folder); a top-level folder holds
84
+ * its boards and its sub-folders; a sub-folder holds its boards. A folder's title and
85
+ * description ride on its item (they live in the registry a tree write rewrites); a board's
86
+ * title stays with its row - it lives in the board's own file. */
70
87
  function buildTree(boards, folders) {
71
- const folderOrder = /* @__PURE__ */ new Map();
72
- const folderMeta = /* @__PURE__ */ new Map();
73
- for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) {
74
- folderOrder.set(f.name, f.order);
75
- folderMeta.set(f.name, folderExtras(f));
76
- }
88
+ const reg = /* @__PURE__ */ new Map();
89
+ for (const f of folders) if (isBoardName(f.name) && !reg.has(f.name)) reg.set(f.name, f);
90
+ const parentOf = (n) => {
91
+ const p = reg.get(n)?.parent;
92
+ return p !== void 0 && p !== n && reg.has(p) && reg.get(p).parent === void 0 ? p : void 0;
93
+ };
77
94
  const members = /* @__PURE__ */ new Map();
95
+ const implied = [];
78
96
  const rootBoards = [];
79
97
  for (const b of boards) {
80
98
  if (!isBoardName(b.name) || b.name === "all-scenes") continue;
@@ -83,37 +101,43 @@ function buildTree(boards, folders) {
83
101
  rootBoards.push(b);
84
102
  continue;
85
103
  }
86
- if (!folderOrder.has(folder)) folderOrder.set(folder, void 0);
104
+ if (!reg.has(folder) && !implied.includes(folder)) implied.push(folder);
87
105
  const list = members.get(folder) ?? [];
88
106
  list.push(b);
89
107
  members.set(folder, list);
90
108
  }
91
- const byRank = (a, b) => rank(a.order) - rank(b.order) || a.name.localeCompare(b.name);
92
- const root = [...rootBoards.map((b) => ({
109
+ const sorted = (xs) => xs.sort((a, b) => rank(a.order) - rank(b.order) || (a.item.kind === b.item.kind ? 0 : a.item.kind === "board" ? -1 : 1) || a.item.name.localeCompare(b.item.name)).map((r) => r.item);
110
+ const boardsHere = (name) => (name === null ? rootBoards : members.get(name) ?? []).map((b) => ({
93
111
  item: {
94
112
  kind: "board",
95
113
  name: b.name
96
114
  },
97
115
  order: b.order
98
- })), ...[...folderOrder].map(([name, order]) => ({
116
+ }));
117
+ const folder = (name, kids) => ({
99
118
  item: {
100
119
  kind: "folder",
101
120
  name,
102
- boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name),
103
- ...folderMeta.get(name) ?? {}
121
+ items: sorted(kids),
122
+ ...folderExtras(reg.get(name) ?? {})
104
123
  },
105
- order
106
- }))];
107
- root.sort((a, b) => rank(a.order) - rank(b.order) || (a.item.kind === b.item.kind ? 0 : a.item.kind === "board" ? -1 : 1) || a.item.name.localeCompare(b.item.name));
108
- return root.map((r) => r.item);
124
+ order: reg.get(name)?.order
125
+ });
126
+ const subsOf = (top) => [...reg.keys()].filter((n) => parentOf(n) === top);
127
+ const tops = [...[...reg.keys()].filter((n) => !parentOf(n)), ...implied];
128
+ return sorted([...boardsHere(null), ...tops.map((t) => folder(t, [...boardsHere(t), ...subsOf(t).map((s) => folder(s, boardsHere(s)))]))]);
109
129
  }
110
- /** Every board in reading order - the order the switchers and the landing pick use. */
130
+ /** Every board in reading order, depth-first - the order the switchers and the landing pick use. */
111
131
  function flatten(tree) {
112
132
  const out = [];
113
- for (const it of tree) if (it.kind === "board") out.push(it.name);
114
- else out.push(...it.boards);
133
+ const walk = (items) => {
134
+ for (const it of items) if (it.kind === "board") out.push(it.name);
135
+ else walk(it.items);
136
+ };
137
+ walk(tree);
115
138
  return out;
116
139
  }
140
+ const wireKids = (w) => w.items ?? w.boards ?? [];
117
141
  function validateWire(wire) {
118
142
  if (!Array.isArray(wire)) return "invalid tree";
119
143
  const boards = /* @__PURE__ */ new Set(), folders = /* @__PURE__ */ new Set();
@@ -123,28 +147,51 @@ function validateWire(wire) {
123
147
  boards.add(n);
124
148
  return null;
125
149
  };
126
- for (const w of wire) {
127
- if (typeof w === "string") {
128
- const e = board(w);
150
+ const walk = (list, depth) => {
151
+ for (const w of list) {
152
+ if (typeof w === "string") {
153
+ const e = board(w);
154
+ if (e) return e;
155
+ continue;
156
+ }
157
+ if (!w || typeof w !== "object" || Array.isArray(w)) return "invalid tree item";
158
+ if (depth >= 2) return "folders nest one level only";
159
+ const { folder, items, boards: legacy, title, description } = w;
160
+ if (!isBoardName(folder)) return "invalid folder name in tree";
161
+ if (title !== void 0 && (typeof title !== "string" || Array.from(title).length > 120)) return "invalid folder title";
162
+ if (description !== void 0 && (typeof description !== "string" || description.length > 300)) return "invalid folder description";
163
+ if (folders.has(folder)) return `folder "${folder}" appears twice`;
164
+ folders.add(folder);
165
+ const kids = items ?? legacy;
166
+ if (!Array.isArray(kids)) return "invalid folder in tree";
167
+ if (items === void 0 && kids.some((k) => typeof k !== "string")) return "invalid folder in tree";
168
+ const e = walk(kids, depth + 1);
129
169
  if (e) return e;
130
- continue;
131
170
  }
132
- if (!w || typeof w !== "object" || Array.isArray(w)) return "invalid tree item";
133
- const { folder, boards: kids, title, description } = w;
134
- if (!isBoardName(folder)) return "invalid folder name in tree";
135
- if (title !== void 0 && (typeof title !== "string" || Array.from(title).length > 120)) return "invalid folder title";
136
- if (description !== void 0 && (typeof description !== "string" || description.length > 300)) return "invalid folder description";
137
- if (folders.has(folder)) return `folder "${folder}" appears twice`;
138
- folders.add(folder);
139
- if (!Array.isArray(kids)) return "invalid folder in tree";
140
- for (const k of kids) {
141
- const e = board(k);
142
- if (e) return e;
143
- }
144
- }
171
+ return null;
172
+ };
173
+ const e = walk(wire, 0);
174
+ if (e) return e;
145
175
  if (boards.size > 200 || folders.size > 50) return "tree too large";
146
176
  return null;
147
177
  }
178
+ /** Every folder with the folder it sits in (null = the root), top-level folders first in
179
+ * reading order, each followed by its sub-folders. */
180
+ function folderEntries(t) {
181
+ const out = [];
182
+ for (const it of t) {
183
+ if (it.kind !== "folder") continue;
184
+ out.push({
185
+ folder: it,
186
+ parent: null
187
+ });
188
+ for (const k of it.items) if (k.kind === "folder") out.push({
189
+ folder: k,
190
+ parent: it.name
191
+ });
192
+ }
193
+ return out;
194
+ }
148
195
  //#endregion
149
196
  //#region src/server/boards.ts
150
197
  /**
@@ -287,4 +334,4 @@ function readRegistry(boardsDir) {
287
334
  };
288
335
  }
289
336
  //#endregion
290
- export { listBoardFiles as a, BOARD_NAME as c, flatten as d, isBoardName as f, hash as g, validateWire as h, isRegularFile as i, FOLDERS_FILE as l, readTitle as m, checkBoardsDir as n, nodeExists as o, readDescription as p, checkRealDirs as r, readRegistry as s, boardFields as t, buildTree as u };
337
+ export { wireKids as _, listBoardFiles as a, BOARD_NAME as c, flatten as d, folderEntries as f, validateWire as g, readTitle as h, isRegularFile as i, FOLDERS_FILE as l, readDescription as m, checkBoardsDir as n, nodeExists as o, isBoardName as p, checkRealDirs as r, readRegistry as s, boardFields as t, buildTree as u, hash as v };
@@ -1,10 +1,10 @@
1
- import { a as listBoardFiles, d as flatten, f as isBoardName, n as checkBoardsDir, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BmxcT3Lc.mjs";
1
+ import { a as listBoardFiles, d as flatten, n as checkBoardsDir, p as isBoardName, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BwiDAmPf.mjs";
2
2
  import { join } from "node:path";
3
3
  //#region src/cli/boards.ts
4
4
  /**
5
5
  * `marver boards` - the sidebar as the agent sees it: every folder and board in reading
6
- * order, from the files (no dev server needed). One call answers "what folders exist, what
7
- * is in them, what ranks where" before the agent writes `folder` on a board or edits
6
+ * order - sub-folders indented under their parent - from the files (no dev server needed). One
7
+ * call answers "what folders exist, what is in them, what ranks where" before the agent writes `folder` on a board or edits
8
8
  * `design/boards/_folders.json`. `--json` gives the tree shape the shell uses.
9
9
  */
10
10
  function boardsCommand(root, opts) {
@@ -43,15 +43,24 @@ function boardsCommand(root, opts) {
43
43
  const r = rows.find((x) => x.name === n);
44
44
  return `${n}${title(r?.title)}${order(n)}${desc(r?.description)}`;
45
45
  };
46
- for (const it of tree) {
47
- if (it.kind === "board") {
48
- console.log(boardLine(it.name));
49
- continue;
46
+ const count = (f) => {
47
+ const boards = f.items.filter((k) => k.kind === "board").length, subs = f.items.length - boards;
48
+ return `${boards} board${boards === 1 ? "" : "s"}${subs ? `, ${subs} folder${subs === 1 ? "" : "s"}` : ""}`;
49
+ };
50
+ const print = (items, pad, parent) => {
51
+ for (const it of items) {
52
+ if (it.kind === "board") {
53
+ console.log(`${pad}${boardLine(it.name)}`);
54
+ continue;
55
+ }
56
+ const where = parent ? `sub-folder of ${parent}` : "folder";
57
+ const implied = reg.folders.some((f) => f.name === it.name) ? "" : ", implied by its boards - not in _folders.json";
58
+ console.log(`${pad}${it.name}/${title(it.title)} (${where}, ${count(it)}${implied})${desc(it.description)}`);
59
+ print(it.items, `${pad} `, it.name);
60
+ if (!it.items.length) console.log(`${pad} (empty)`);
50
61
  }
51
- console.log(`${it.name}/${title(it.title)} (folder, ${it.boards.length} board${it.boards.length === 1 ? "" : "s"}${reg.folders.some((f) => f.name === it.name) ? "" : ", implied by its boards - not in _folders.json"})${desc(it.description)}`);
52
- for (const b of it.boards) console.log(` ${boardLine(b)}`);
53
- if (!it.boards.length) console.log(" (empty)");
54
- }
62
+ };
63
+ print(tree, "", null);
55
64
  if (hasAll) console.log("all-scenes (auto, always last)");
56
65
  const landing = flatten(tree)[0];
57
66
  if (landing) console.log(`\nlanding board: ${landing}`);
@@ -1,7 +1,7 @@
1
1
  import { a as ROUTE, r as NAME } from "./cli.mjs";
2
- import { a as listBoardFiles, d as flatten, f as isBoardName, n as checkBoardsDir, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BmxcT3Lc.mjs";
3
- import { c as loadConfig, r as scanFrames, u as detectHost } from "./manifest-B01PSyDc.mjs";
4
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-y4Ch7o_A.mjs";
2
+ import { a as listBoardFiles, d as flatten, n as checkBoardsDir, p as isBoardName, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BwiDAmPf.mjs";
3
+ import { c as loadConfig, r as scanFrames, u as detectHost } from "./manifest-mMfUhPtL.mjs";
4
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-omHLCn91.mjs";
5
5
  import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, join, sep } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -339,15 +339,24 @@ function readBoards(root) {
339
339
  return out;
340
340
  }
341
341
  /** Switcher order = the sidebar's reading order: the folder tree over the PUBLISHED boards only
342
- * (a folder left with no published board drops out - its name never reaches the bundle),
342
+ * (a folder left with no published board at any depth drops out - its name never reaches the bundle),
343
343
  * flattened depth-first; all-scenes always LAST (it is the expensive everything-board, never
344
344
  * the landing). `names[0]` is where `/` opens. Folder names of published boards are structure,
345
345
  * like board names: they ship. */
346
346
  function publishedTree(published, allBoards, folders) {
347
- const tree = buildTree(published.filter((n) => n !== "all-scenes").map((n) => ({
347
+ const built = buildTree(published.filter((n) => n !== "all-scenes").map((n) => ({
348
348
  name: n,
349
349
  ...boardFields(allBoards[n], isBoardName)
350
- })), folders).filter((it) => it.kind === "board" || it.boards.length > 0);
350
+ })), folders);
351
+ const prune = (items) => items.flatMap((it) => {
352
+ if (it.kind === "board") return [it];
353
+ const kids = prune(it.items);
354
+ return kids.length ? [{
355
+ ...it,
356
+ items: kids
357
+ }] : [];
358
+ });
359
+ const tree = prune(built);
351
360
  return {
352
361
  tree,
353
362
  names: [...flatten(tree), ...published.includes("all-scenes") ? ["all-scenes"] : []]
@@ -361,6 +370,7 @@ function publishedManifest(manifest, pubFrames, publishedNames, strip) {
361
370
  const pubBoardSet = new Set(publishedNames);
362
371
  const pubBoards = (manifest.boards ?? []).filter((b) => pubBoardSet.has(b.name));
363
372
  const pubFolderSet = new Set(pubBoards.map((b) => b.folder).filter(Boolean));
373
+ for (const f of manifest.folders ?? []) if (f.parent && pubFolderSet.has(f.name)) pubFolderSet.add(f.parent);
364
374
  const pubFolders = (manifest.folders ?? []).filter((f) => pubFolderSet.has(f.name));
365
375
  return {
366
376
  ...manifest.project ? { project: manifest.project } : {},
@@ -683,7 +693,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds, textures =
683
693
  const realAssets = existsSync(assetsDir) ? realpathSync(assetsDir) : null;
684
694
  for (const r of refs) {
685
695
  if (!isLocalAssetRef(r) || !r.endsWith(".poster.png") || existsSync(join(assetsDir, r))) continue;
686
- const { ensurePoster } = await import("./poster-FCv_nXyP.mjs");
696
+ const { ensurePoster } = await import("./poster-BvxiAzy1.mjs");
687
697
  const g = await ensurePoster(assetsDir, r.slice(0, -11));
688
698
  if (!g.ok) throw new Error(`design/assets/${r}: ${g.error}`);
689
699
  console.log(` poster: rendered design/assets/${r} (${g.width}×${g.height})`);
@@ -750,7 +760,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds, textures =
750
760
  }));
751
761
  let textureLine = " textures: skipped (--no-textures)";
752
762
  if (textures && bakeGen) {
753
- const { bakePublished } = await import("./publish-bakes-XH6Bac58.mjs");
763
+ const { bakePublished } = await import("./publish-bakes-BqzAAa3w.mjs");
754
764
  const pubFrameFile = new Map(pubFrames.map((f) => [f.id, f]));
755
765
  const r = await bakePublished({
756
766
  root,
package/dist/cli.mjs CHANGED
@@ -78,14 +78,14 @@ const resolve$1 = (dir) => {
78
78
  };
79
79
  const cli = cac(NAME);
80
80
  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) => {
81
- const { init } = await import("./init-DKxuRxBr.mjs");
81
+ const { init } = await import("./init-C34BY3R4.mjs");
82
82
  init(resolve$1(opts.root), {
83
83
  mode: opts.mode === "embedded" ? "embedded" : "studio",
84
84
  demo: opts.demo !== false
85
85
  });
86
86
  });
87
87
  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) => {
88
- const { dev } = await import("./dev-BjdDP69b.mjs");
88
+ const { dev } = await import("./dev-BNZF4Mup.mjs");
89
89
  let port;
90
90
  if (opts.port !== void 0) {
91
91
  const n = Number(opts.port);
@@ -95,7 +95,7 @@ for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot
95
95
  await dev(resolve$1(opts.root), port);
96
96
  });
97
97
  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("--no-textures", "Skip compiling the glass textures the published frames rest under (needs Chrome; a CI without one skips on its own)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
98
- const { buildSite } = await import("./build-DwpNPl6Z.mjs");
98
+ const { buildSite } = await import("./build-C7MqQ7hq.mjs");
99
99
  try {
100
100
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
101
101
  await buildSite(resolve$1(opts.root), boards, opts.allBoards === true, opts.embedSeeds === true, opts.textures !== false && !process.env.MARVER_NO_TEXTURES);
@@ -141,7 +141,7 @@ cli.command("work <action> [...frames]", "Working state on the canvas: start <sc
141
141
  }
142
142
  });
143
143
  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) => {
144
- const { boardsCommand } = await import("./boards-PuVzw5Wp.mjs");
144
+ const { boardsCommand } = await import("./boards-DnLewfj8.mjs");
145
145
  try {
146
146
  boardsCommand(resolve$1(opts.root), opts);
147
147
  } 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 { a as toFrameId } from "./manifest-B01PSyDc.mjs";
4
+ import { a as toFrameId } from "./manifest-mMfUhPtL.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 { i as PKG, r as NAME } from "./cli.mjs";
2
- import { c as loadConfig, u as detectHost } from "./manifest-B01PSyDc.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-y4Ch7o_A.mjs";
2
+ import { c as loadConfig, u as detectHost } from "./manifest-mMfUhPtL.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-omHLCn91.mjs";
4
4
  import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -452,12 +452,12 @@ async function dev(root, portFlag) {
452
452
  console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
453
453
  setTimeout(async () => {
454
454
  try {
455
- await (await import("./shot-iw3SEcpn.mjs")).sweepGhosts((m) => console.log(` ${m}`));
455
+ await (await import("./shot-DswS4iRK.mjs")).sweepGhosts((m) => console.log(` ${m}`));
456
456
  } catch {}
457
457
  }, 0);
458
458
  try {
459
459
  const { staleManagedInstructions } = await import("./managed-HwHVNI3h.mjs");
460
- const { installedVersion } = await import("./plugin-y4Ch7o_A.mjs").then((n) => n.i);
460
+ const { installedVersion } = await import("./plugin-omHLCn91.mjs").then((n) => n.i);
461
461
  const stale = staleManagedInstructions(root);
462
462
  if (stale.length) {
463
463
  const shown = stale.slice(0, 4).join(", ") + (stale.length > 4 ? `, +${stale.length - 4} more` : "");
@@ -495,7 +495,7 @@ async function dev(root, portFlag) {
495
495
  });
496
496
  }
497
497
  if (config.jam) {
498
- const { startJam } = await import("./daemon-DbHvLQUL.mjs");
498
+ const { startJam } = await import("./daemon-CRZFpl6K.mjs");
499
499
  const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
500
500
  if (jam) {
501
501
  const close = server.close.bind(server);
@@ -1,5 +1,5 @@
1
1
  import { r as NAME } from "./cli.mjs";
2
- import { d as readJson, l as detectAgent, o as writeManifest, r as scanFrames, s as DEFAULTS, u as detectHost } from "./manifest-B01PSyDc.mjs";
2
+ import { d as readJson, l as detectAgent, o as writeManifest, r as scanFrames, s as DEFAULTS, u as detectHost } from "./manifest-mMfUhPtL.mjs";
3
3
  import { MANAGED_PREFIX, enumerateInstructionTemplates, hashBody, managedFile, pkgDir } from "./managed-HwHVNI3h.mjs";
4
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, relative } from "node:path";
@@ -1,5 +1,5 @@
1
1
  import { i as PKG, n as CONTENT_WIDTH } from "./cli.mjs";
2
- import { a as listBoardFiles, d as flatten, f as isBoardName, g as hash, m as readTitle, n as checkBoardsDir, p as readDescription, r as checkRealDirs, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BmxcT3Lc.mjs";
2
+ import { a as listBoardFiles, d as flatten, f as folderEntries, h as readTitle, m as readDescription, n as checkBoardsDir, p as isBoardName, r as checkRealDirs, s as readRegistry, t as boardFields, u as buildTree, v as hash } from "./boards-BwiDAmPf.mjs";
3
3
  import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { delimiter, isAbsolute, join, relative, sep } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
@@ -615,8 +615,9 @@ function setSceneTitle(root, scene, title) {
615
615
  }
616
616
  return null;
617
617
  }
618
- /** The sidebar as files say it is, for the manifest: folders in root order, boards in
619
- * reading order with their folder and description. A boards dir we may not read (symlink)
618
+ /** The sidebar as files say it is, for the manifest: folders in reading order (a sub-folder
619
+ * after its parent, with `parent`), boards in reading order with the folder they sit in
620
+ * directly and their description. A boards dir we may not read (symlink)
620
621
  * or a malformed registry yields nothing here - the API and the build say why; the
621
622
  * orientation file must never fail to write. */
622
623
  function scanBoards(root) {
@@ -629,10 +630,12 @@ function scanBoards(root) {
629
630
  ...boardFields(b.json, isBoardName)
630
631
  }));
631
632
  const tree = buildTree(rows, reg.folders);
633
+ const entries = folderEntries(tree);
632
634
  const folderOf = /* @__PURE__ */ new Map();
633
- for (const it of tree) if (it.kind === "folder") for (const b of it.boards) folderOf.set(b, it.name);
634
- const folders = tree.filter((it) => it.kind === "folder").map((it) => ({
635
+ for (const { folder } of entries) for (const k of folder.items) if (k.kind === "board") folderOf.set(k.name, folder.name);
636
+ const folders = entries.map(({ folder: it, parent }) => ({
635
637
  name: it.name,
638
+ ...parent ? { parent } : {},
636
639
  ...it.title ? { title: it.title } : {},
637
640
  ...it.description ? { description: it.description } : {}
638
641
  }));
@@ -1,8 +1,8 @@
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 { _ as wireKids, a as listBoardFiles, c as BOARD_NAME, g as validateWire, h as readTitle, i as isRegularFile, l as FOLDERS_FILE, m as readDescription, n as checkBoardsDir, o as nodeExists, s as readRegistry, t as boardFields, v as hash } from "./boards-BwiDAmPf.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 { c as loadConfig, i as setSceneTitle, n as isNoteFile, o as writeManifest, r as scanFrames, t as affectedFrameIds } from "./manifest-mMfUhPtL.mjs";
6
6
  import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
7
7
  import { basename, dirname, join, resolve, sep } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
@@ -261,6 +261,7 @@ function apiMiddleware(root, opts = {}) {
261
261
  }
262
262
  const reg = readRegistry(boardsDir);
263
263
  if (reg.state === "malformed") return json(res, 422, { error: reg.error });
264
+ if (reg.state === "ok" && reg.folders.some((f) => f.parent) && parsed.protocol !== 2) return json(res, 422, { error: "this tab predates folders in folders - reload the canvas, then try again" });
264
265
  if (reg.sha256 !== b.folders) return json(res, 409, {
265
266
  error: "folders changed on disk",
266
267
  stale: [FOLDERS_FILE]
@@ -300,23 +301,29 @@ function apiMiddleware(root, opts = {}) {
300
301
  return null;
301
302
  };
302
303
  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;
308
- }
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 } : {}
315
- });
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 });
304
+ const walk = (list, parent) => {
305
+ for (const [i, it] of list.entries()) {
306
+ if (typeof it === "string") {
307
+ const e = consider(it, i, parent);
308
+ if (e) return e;
309
+ continue;
310
+ }
311
+ const title = readTitle(it.title), description = readDescription(it.description);
312
+ folders.push({
313
+ name: it.folder,
314
+ order: i,
315
+ ...parent ? { parent } : {},
316
+ ...title ? { title } : {},
317
+ ...description ? { description } : {}
318
+ });
319
+ const e = walk(wireKids(it), it.folder);
320
+ if (e) return e;
319
321
  }
322
+ return null;
323
+ };
324
+ {
325
+ const e = walk(tree, null);
326
+ if (e) return json(res, 422, { error: e });
320
327
  }
321
328
  if (stale.length) return json(res, 409, {
322
329
  error: "boards changed on disk",
@@ -335,8 +342,9 @@ function apiMiddleware(root, opts = {}) {
335
342
  mkdirSync(boardsDir, { recursive: true });
336
343
  let foldersSha = null;
337
344
  if (folders.length) {
345
+ const version = folders.some((f) => f.parent) ? 2 : 1;
338
346
  const next = JSON.stringify({
339
- version: 1,
347
+ version,
340
348
  folders
341
349
  }, null, 2) + "\n";
342
350
  atomicWrite(foldersPath, next);
@@ -422,7 +430,7 @@ function apiMiddleware(root, opts = {}) {
422
430
  };
423
431
  const asPng = url.searchParams.get("format") === "png";
424
432
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
425
- const { shootFrame } = await import("./shot-iw3SEcpn.mjs");
433
+ const { shootFrame } = await import("./shot-DswS4iRK.mjs");
426
434
  const r = await shootFrame({
427
435
  root,
428
436
  viewports: opts.viewports ?? {},
@@ -472,7 +480,7 @@ function apiMiddleware(root, opts = {}) {
472
480
  if (typeof theme !== "string" || !/^[a-z0-9-]+$/i.test(theme)) return json(res, 400, { error: "invalid theme" });
473
481
  const scale = body.scale === void 0 ? void 0 : body.scale;
474
482
  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");
483
+ const { resolveFrames, shootBatch } = await import("./shot-DswS4iRK.mjs");
476
484
  const sel = resolveFrames(root, body);
477
485
  if (!sel.ok) return json(res, sel.status, { error: sel.error });
478
486
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
@@ -500,7 +508,7 @@ function apiMiddleware(root, opts = {}) {
500
508
  return json(res, 400, { error: "malformed JSON" });
501
509
  }
502
510
  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");
511
+ const { bakeBatch, ASK_MAX } = await import("./bake-BID6mo-N.mjs");
504
512
  let manifest = {};
505
513
  try {
506
514
  manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
@@ -544,8 +552,8 @@ function apiMiddleware(root, opts = {}) {
544
552
  if (path === "poster" && req.method === "GET") {
545
553
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
546
554
  const src = url.searchParams.get("src") ?? "";
547
- const { ensurePoster, isLocalClip } = await import("./poster-FCv_nXyP.mjs");
548
- const { isLocalAssetRef } = await import("./build-DwpNPl6Z.mjs");
555
+ const { ensurePoster, isLocalClip } = await import("./poster-BvxiAzy1.mjs");
556
+ const { isLocalAssetRef } = await import("./build-C7MqQ7hq.mjs");
549
557
  if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
550
558
  const r = await ensurePoster(join(root, "design", "assets"), src);
551
559
  if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
@@ -1027,7 +1035,7 @@ function marverPlugin(ctx) {
1027
1035
  };
1028
1036
  const prune = () => setTimeout(async () => {
1029
1037
  try {
1030
- (await import("./bake-tAb0D6Rc.mjs")).pruneBakes(root, bakeGen);
1038
+ (await import("./bake-BID6mo-N.mjs")).pruneBakes(root, bakeGen);
1031
1039
  } catch {}
1032
1040
  }, 500);
1033
1041
  prune();
@@ -1166,7 +1174,7 @@ function marverPlugin(ctx) {
1166
1174
  });
1167
1175
  return;
1168
1176
  }
1169
- const { shootFrame, shootBatch, resolveFrames } = await import("./shot-iw3SEcpn.mjs");
1177
+ const { shootFrame, shootBatch, resolveFrames } = await import("./shot-DswS4iRK.mjs");
1170
1178
  if (ways[0] === "frame") {
1171
1179
  if (typeof spec.frame !== "string") {
1172
1180
  write({