@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,81 @@
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.16.0 - 2026-09-03
6
+
7
+ ### Added
8
+
9
+ - **Board folders.** The sidebar's boards can live in folders, one level deep.
10
+ Right-click the Boards header (or its `+`) for a new folder and name it
11
+ inline - what you type becomes a slug ("Old stuff" → `old-stuff`, shown "Old
12
+ Stuff"); right-click a board for **Move to new folder** (the folder takes the
13
+ board's slot, the input has focus) or **Move to top level**; right-click a
14
+ folder to **Rename** or **Delete** it (its boards go back to the top level -
15
+ folders organise, never own). Moving into an existing folder is a **drag**:
16
+ the board drag-and-drop now lands boards inside folders (drop on the folder
17
+ row), in any slot inside one, back out to the root (or the left gutter of a
18
+ folder's rows), and drags folders among boards. A click collapses a folder; the
19
+ choice is remembered per browser. The folder holding the active board keeps
20
+ the ancestor wash so its home stays visible collapsed.
21
+ - **Folders are files, for agents too.** `"folder": "<name>"` on a board file
22
+ puts it in a folder (`order` ranks it among its folder siblings);
23
+ `design/boards/_folders.json` names empty folders and ranks folders at the
24
+ root. A folder two boards name exists without the registry. The landing
25
+ board is the first board in sidebar order, folders included. **`npx marver
26
+ boards`** prints the sidebar as the files say it is (folders, boards in
27
+ reading order with their `order`, the landing board; `--json` for the tree),
28
+ so an agent looks before it organises. The agent contract and
29
+ `instructions/boards.md` carry the grammar, every move (create, file in,
30
+ move out, rank, rename, delete) as a file edit, and a nudge to group
31
+ proactively past six or eight boards.
32
+ - **Published bundles** carry the folder tree of the published boards only; a
33
+ folder with nothing published never reaches the bundle. The published
34
+ sidebar shows folders read-only.
35
+ - **Descriptions - purpose notes on every object, for agents.** One optional
36
+ `description` (one sentence: what it is for, its state) on the project
37
+ (`description` in `design/config.ts`), a board (its JSON - preserved by
38
+ autosave and by sidebar drags like `order`/`folder`), a folder (its
39
+ `_folders.json` entry - it rides along through renames), a scene (the first
40
+ non-blank line of its `_brief.md`, `#` stripped, front matter skipped - no
41
+ new file) and a frame
42
+ (`meta.description`). `design/manifest.json` becomes the orientation file it
43
+ was meant to be: `project`, `folders`, `boards` (sidebar order, with folder
44
+ and description), `scenes` (with description and brief path) and `frames`
45
+ (with description). `marver dev` regenerates it on board and brief edits too,
46
+ and broadcasts `sh:manifest` only when the frames changed - a description
47
+ edit never re-keys the live iframes. Published bundles ship descriptions of
48
+ published things only (brief paths only with source revealed). `marver
49
+ boards` prints them. Editing `description` in `design/config.ts` under `dev`
50
+ refreshes the manifest live (the rest of the config still needs a restart).
51
+ The agent contract teaches: write it at creation, keep it true, fix what your
52
+ session made false before it ends (the review walk ends on it). Nothing
53
+ renders in the canvas.
54
+ - `export const meta` picks a literal even when the prose holds the other
55
+ quote (`"the buyer's path"`), and refuses a computed value (`"Draft" + phase`)
56
+ instead of taking its literal half.
57
+
58
+ ### Changed
59
+
60
+ - `POST /__mv/api/boards/reorder` takes the WHOLE tree (`{ tree, base }`) in
61
+ place of `{ order }`: root boards as strings, folders as `{ folder, boards }`,
62
+ plus the sha256 the client last saw for every board it names and for the
63
+ registry. The server preflights every named board (present, a regular file,
64
+ well-formed, unchanged) BEFORE writing anything - a 409 names the stale
65
+ boards and the shell re-reads and replays its move once, so an agent's
66
+ concurrent `folder` edit and a human's drag can never silently erase each
67
+ other. It answers the new hashes, and the shell's autosave of the active
68
+ board keeps its CAS token current - a reorder no longer reboots the board
69
+ you are editing. `GET /__mv/api/boards` gains `folder`; `GET
70
+ /__mv/api/folders` is new.
71
+ - Board enumeration (dev API, build, `--all-boards`) counts only regular files
72
+ on the board-name grammar: `_folders.json`, temp files and symlinks are never
73
+ boards - the build fails closed on a symlinked board. A malformed
74
+ `_folders.json` is a 422 the sidebar toasts (and a build error), never a
75
+ silently empty registry the next drag would overwrite.
76
+ - The sidebar re-reads its list on a coalesced `sh:boards` broadcast (any add,
77
+ write or delete under `design/boards/`), so agent-written boards and folders
78
+ show in under half a second instead of the 8 s poll.
79
+
5
80
  ## 0.15.0 - 2026-09-02
6
81
 
7
82
  ### Added
package/README.md CHANGED
@@ -40,7 +40,7 @@ Frames appear on the canvas the moment the files land. That's the loop.
40
40
 
41
41
  ## The canvas
42
42
 
43
- - **Frames, scenes, boards.** Frames are screens, scenes group them (`design/scenes/<scene>/<frame>.tsx`), boards arrange them. Agents write `design/boards/<name>.json` (a frame list is enough); switch boards at the top of the sidebar. `all-scenes` is auto-managed. Right-click any board, scene, or frame in the sidebar to copy its path - the exact string to paste to your agent - and rename or drag-reorder boards from there too.
43
+ - **Frames, scenes, boards.** Frames are screens, scenes group them (`design/scenes/<scene>/<frame>.tsx`), boards arrange them. Agents write `design/boards/<name>.json` (a frame list is enough); switch boards at the top of the sidebar. `all-scenes` is auto-managed. Right-click any board, scene, or frame in the sidebar to copy its path - the exact string to paste to your agent - and rename or drag-reorder boards from there too. Boards can live in folders: right-click the Boards header (or its `+`) for a new one, name it inline, drag boards in and out and folders among boards; agents do the same by writing `"folder": "<name>"` on a board and `design/boards/_folders.json` for empty or ranked folders (`npx marver boards` prints the tree). Every object takes a one-sentence `description` (project in `design/config.ts`, boards and folders in their JSON, a scene's first `_brief.md` line, `meta.description` on a frame) and `design/manifest.json` carries them all - a new agent session orients in one read.
44
44
  - **Devices view.** Hotkeys `1`-`5` (or the Devices menu) size every frame to mobile / tablet / laptop / monitor / tv to sweep your breakpoints; `0` restores your own layout exactly. Widths live in `design/config.ts`.
45
45
  - **Prototype links.** `data-goto="scene/frame"` on any element links frames into a walkable prototype - across boards, too.
46
46
  - **Five ways to view a board.** The canvas (frames on a plane), the board (the same, tidy), **present** (`p`: a full-screen clickable walkthrough - `data-goto` navigates, arrows step, `[` / `]` cycle variants, laser, comments, theme and device pickers in the toolbar), **focus** (one frame as a document - the reading preset for specs), and **slides** (a deck). A published board names its landing view; a frame deep link opens straight into it.
@@ -98,6 +98,7 @@ The same glow, driven from the terminal. When your agent takes a request, it cre
98
98
  | `npx marver comments …` | The agent's queue: `connect <url>` · `sync` · `list` · `reply` · `resolve` · `invite <email>` · `revoke <email>` |
99
99
  | `npx marver work …` | Working glow from the terminal: `start <scene/frame …>` · `done … \| --all` · `list` |
100
100
  | `npx marver shot <frame> [--scale 1-4]` | Render one frame headless and print the PNG path (needs `dev` running); 2x by default |
101
+ | `npx marver boards [--json]` | The sidebar as the files say it is: folders, boards in reading order with `order` and description, the landing board |
101
102
 
102
103
  ## Shortcuts
103
104
 
@@ -0,0 +1,57 @@
1
+ import { d as isBoardName, i as listBoardFiles, l as buildTree, n as checkBoardsDir, o as readRegistry, t as boardFields, u as flatten } from "./boards-BdG1TJwU.mjs";
2
+ import { join } from "node:path";
3
+ //#region src/cli/boards.ts
4
+ /**
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
8
+ * `design/boards/_folders.json`. `--json` gives the tree shape the shell uses.
9
+ */
10
+ function boardsCommand(root, opts) {
11
+ const dir = join(root, "design", "boards");
12
+ const de = checkBoardsDir(root, dir);
13
+ if (de) throw new Error(de);
14
+ const { boards, skipped } = listBoardFiles(dir);
15
+ const reg = readRegistry(dir);
16
+ if (reg.state === "malformed") throw new Error(reg.error);
17
+ const rows = boards.map((b) => ({
18
+ name: b.name,
19
+ ...boardFields(b.json, isBoardName)
20
+ }));
21
+ const tree = buildTree(rows, reg.folders);
22
+ const hasAll = boards.some((b) => b.name === "all-scenes");
23
+ if (opts.json) {
24
+ console.log(JSON.stringify({
25
+ tree,
26
+ boards: rows.filter((r) => r.name !== "all-scenes"),
27
+ landing: flatten(tree)[0] ?? (hasAll ? "all-scenes" : null),
28
+ registry: reg.state === "ok" ? "design/boards/_folders.json" : null
29
+ }, null, 2));
30
+ return;
31
+ }
32
+ if (!tree.length && !hasAll) {
33
+ console.log("no boards yet - design/boards/ is empty");
34
+ return;
35
+ }
36
+ const order = (n) => {
37
+ const o = rows.find((r) => r.name === n)?.order;
38
+ return o === void 0 ? "" : ` order ${o}`;
39
+ };
40
+ const desc = (d) => d ? ` - ${d}` : "";
41
+ for (const it of tree) {
42
+ if (it.kind === "board") {
43
+ console.log(`${it.name}${order(it.name)}${desc(rows.find((r) => r.name === it.name)?.description)}`);
44
+ continue;
45
+ }
46
+ console.log(`${it.name}/ (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)}`);
47
+ for (const b of it.boards) console.log(` ${b}${order(b)}${desc(rows.find((r) => r.name === b)?.description)}`);
48
+ if (!it.boards.length) console.log(" (empty)");
49
+ }
50
+ if (hasAll) console.log("all-scenes (auto, always last)");
51
+ const landing = flatten(tree)[0];
52
+ if (landing) console.log(`\nlanding board: ${landing}`);
53
+ console.log(`registry: ${reg.state === "ok" ? "design/boards/_folders.json" : "none (no empty or ranked folders yet)"}`);
54
+ if (skipped.length) console.log(`skipped (not regular files): ${skipped.join(", ")}`);
55
+ }
56
+ //#endregion
57
+ export { boardsCommand };
@@ -0,0 +1,267 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
2
+ import { join, sep } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ //#region src/server/hash.ts
5
+ /** sha256 hex of a string - the CAS token for board files, the registry, the manifest. */
6
+ const hash = (s) => createHash("sha256").update(s).digest("hex");
7
+ //#endregion
8
+ //#region src/shared/board-tree.ts
9
+ /**
10
+ * Board folders - the pure tree shared by the sidebar, the dev API, the build and the
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.
15
+ */
16
+ /** The on-disk name grammar shared by boards and folders (a board name is a filename). */
17
+ const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
18
+ const isBoardName = (n) => typeof n === "string" && n.length >= 1 && n.length <= 64 && BOARD_NAME.test(n);
19
+ /** The folder registry beside the boards - underscore = infrastructure, never a board. */
20
+ const FOLDERS_FILE = "_folders.json";
21
+ /** Is this basename in design/boards/ a board file? `_folders.json`, temp files and any
22
+ * off-grammar name are not - every lister (dev API, build, watcher) shares this rule. */
23
+ const isBoardFile = (f) => f.endsWith(".json") && isBoardName(f.slice(0, -5));
24
+ const readDescription = (v) => {
25
+ if (typeof v !== "string") return void 0;
26
+ return v.trim().replace(/\s+/g, " ").slice(0, 300) || void 0;
27
+ };
28
+ const rank = (o) => typeof o === "number" && Number.isFinite(o) ? o : Infinity;
29
+ /** The registry file's shape. Returns the rows, or a string naming what is wrong - a
30
+ * malformed registry is an ERROR the human must fix (silently reading it as empty would
31
+ * let the next drag overwrite their folders), while a missing file is simply no folders. */
32
+ function parseFolders(raw) {
33
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "expected an object";
34
+ const { version, folders } = raw;
35
+ if (version !== void 0 && version !== 1) return `unsupported version ${String(version)}`;
36
+ if (!Array.isArray(folders)) return "expected a \"folders\" array";
37
+ const out = [];
38
+ const seen = /* @__PURE__ */ new Set();
39
+ for (const f of folders) {
40
+ const name = f?.name;
41
+ if (!isBoardName(name)) return "a folder needs a name - lowercase letters, numbers and dashes";
42
+ if (seen.has(name)) return `folder "${name}" is listed twice`;
43
+ seen.add(name);
44
+ const o = f.order;
45
+ const d = readDescription(f.description);
46
+ out.push({
47
+ name,
48
+ ...typeof o === "number" && Number.isFinite(o) ? { order: o } : {},
49
+ ...d ? { description: d } : {}
50
+ });
51
+ }
52
+ return out;
53
+ }
54
+ /** Sidebar order from the files. Root: boards with no folder + every folder (registered
55
+ * or implied by a board), ranked by `order` then kind (board before folder) then name.
56
+ * Inside a folder: its boards by `order` then name. Unranked sorts after ranked. */
57
+ function buildTree(boards, folders) {
58
+ const folderOrder = /* @__PURE__ */ new Map();
59
+ const folderDesc = /* @__PURE__ */ new Map();
60
+ for (const f of folders) if (isBoardName(f.name) && !folderOrder.has(f.name)) {
61
+ folderOrder.set(f.name, f.order);
62
+ if (f.description) folderDesc.set(f.name, f.description);
63
+ }
64
+ const members = /* @__PURE__ */ new Map();
65
+ const rootBoards = [];
66
+ for (const b of boards) {
67
+ if (!isBoardName(b.name) || b.name === "all-scenes") continue;
68
+ const folder = isBoardName(b.folder) ? b.folder : void 0;
69
+ if (!folder) {
70
+ rootBoards.push(b);
71
+ continue;
72
+ }
73
+ if (!folderOrder.has(folder)) folderOrder.set(folder, void 0);
74
+ const list = members.get(folder) ?? [];
75
+ list.push(b);
76
+ members.set(folder, list);
77
+ }
78
+ const byRank = (a, b) => rank(a.order) - rank(b.order) || a.name.localeCompare(b.name);
79
+ const root = [...rootBoards.map((b) => ({
80
+ item: {
81
+ kind: "board",
82
+ name: b.name
83
+ },
84
+ order: b.order
85
+ })), ...[...folderOrder].map(([name, order]) => ({
86
+ item: {
87
+ kind: "folder",
88
+ name,
89
+ boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name),
90
+ ...folderDesc.has(name) ? { description: folderDesc.get(name) } : {}
91
+ },
92
+ order
93
+ }))];
94
+ 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));
95
+ return root.map((r) => r.item);
96
+ }
97
+ /** Every board in reading order - the order the switchers and the landing pick use. */
98
+ function flatten(tree) {
99
+ const out = [];
100
+ for (const it of tree) if (it.kind === "board") out.push(it.name);
101
+ else out.push(...it.boards);
102
+ return out;
103
+ }
104
+ function validateWire(wire) {
105
+ if (!Array.isArray(wire)) return "invalid tree";
106
+ const boards = /* @__PURE__ */ new Set(), folders = /* @__PURE__ */ new Set();
107
+ const board = (n) => {
108
+ if (!isBoardName(n) || n === "all-scenes") return "invalid board name in tree";
109
+ if (boards.has(n)) return `board "${n}" appears twice`;
110
+ boards.add(n);
111
+ return null;
112
+ };
113
+ for (const w of wire) {
114
+ if (typeof w === "string") {
115
+ const e = board(w);
116
+ if (e) return e;
117
+ continue;
118
+ }
119
+ if (!w || typeof w !== "object" || Array.isArray(w)) return "invalid tree item";
120
+ const { folder, boards: kids, description } = w;
121
+ if (!isBoardName(folder)) return "invalid folder name in tree";
122
+ if (description !== void 0 && (typeof description !== "string" || description.length > 300)) return "invalid folder description";
123
+ if (folders.has(folder)) return `folder "${folder}" appears twice`;
124
+ folders.add(folder);
125
+ if (!Array.isArray(kids)) return "invalid folder in tree";
126
+ for (const k of kids) {
127
+ const e = board(k);
128
+ if (e) return e;
129
+ }
130
+ }
131
+ if (boards.size > 200 || folders.size > 50) return "tree too large";
132
+ return null;
133
+ }
134
+ //#endregion
135
+ //#region src/server/boards.ts
136
+ /**
137
+ * Reading design/boards/ safely - the one enumerator the dev API, the build and the tests
138
+ * share. Only REGULAR files on the board-name grammar count as boards (a symlink, dangling or
139
+ * live, could read or publish JSON from outside the project - it is skipped, and reported so
140
+ * the build can fail closed). The folder registry is read the same way: absent = no folders,
141
+ * malformed = an error the human must fix, never a silently empty registry.
142
+ */
143
+ /** Does realpath(dir) stay inside realpath(root)? A symlinked design/boards can't escape. */
144
+ function underRoot(root, dir) {
145
+ try {
146
+ const rr = realpathSync(root);
147
+ const rd = realpathSync(dir);
148
+ return rd === rr || rd.startsWith(rr + sep);
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+ /** Is design/boards a directory we may read and write? It must not be a symlink at all (a
154
+ * link to the repo root would list package.json as a board and let a tree write rewrite
155
+ * it; a link outside would publish foreign JSON) and must resolve inside the root. Absent
156
+ * is fine (no boards yet). Returns the error, or null. */
157
+ function checkBoardsDir(root, boardsDir) {
158
+ const design = join(boardsDir, "..");
159
+ for (const [p, label] of [[design, "design"], [boardsDir, "design/boards"]]) {
160
+ try {
161
+ if (lstatSync(p).isSymbolicLink()) return `${label} must be a real directory, not a symlink`;
162
+ } catch {
163
+ return null;
164
+ }
165
+ if (!underRoot(root, p)) return `${label} escapes the project`;
166
+ }
167
+ return null;
168
+ }
169
+ /** A regular file (lstat: a symlink is never followed, a dangling one is not "absent"). */
170
+ const isRegularFile = (p) => {
171
+ try {
172
+ return lstatSync(p).isFile();
173
+ } catch {
174
+ return false;
175
+ }
176
+ };
177
+ /** Is there ANY node at p (a dangling symlink counts)? */
178
+ const nodeExists = (p) => {
179
+ try {
180
+ lstatSync(p);
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ };
186
+ /** Every board file: name, raw content, hash, and its JSON (null when malformed). `skipped`
187
+ * names the entries that looked like boards but were not regular files. */
188
+ function listBoardFiles(boardsDir) {
189
+ const boards = [], skipped = [];
190
+ if (!existsSync(boardsDir)) return {
191
+ boards,
192
+ skipped
193
+ };
194
+ for (const f of readdirSync(boardsDir)) {
195
+ if (!isBoardFile(f)) continue;
196
+ const file = join(boardsDir, f);
197
+ if (!isRegularFile(file)) {
198
+ skipped.push(f);
199
+ continue;
200
+ }
201
+ const content = readFileSync(file, "utf8");
202
+ let json = null;
203
+ try {
204
+ json = JSON.parse(content);
205
+ } catch {}
206
+ boards.push({
207
+ name: f.slice(0, -5),
208
+ file,
209
+ content,
210
+ sha256: hash(content),
211
+ json
212
+ });
213
+ }
214
+ return {
215
+ boards,
216
+ skipped
217
+ };
218
+ }
219
+ /** The author-owned sidebar fields off a board's JSON, leniently. */
220
+ function boardFields(json, validName) {
221
+ const o = json;
222
+ const description = readDescription(o?.description);
223
+ return {
224
+ ...typeof o?.order === "number" && Number.isFinite(o.order) ? { order: o.order } : {},
225
+ ...validName(o?.folder) ? { folder: o.folder } : {},
226
+ ...description ? { description } : {}
227
+ };
228
+ }
229
+ /** The folder registry. `sha256` is the CAS token a tree write must echo (null = "there was
230
+ * no file"), so a write can never silently replace a registry it never saw. */
231
+ function readRegistry(boardsDir) {
232
+ const p = join(boardsDir, FOLDERS_FILE);
233
+ if (!nodeExists(p)) return {
234
+ state: "absent",
235
+ folders: [],
236
+ sha256: null
237
+ };
238
+ if (!isRegularFile(p)) return {
239
+ state: "malformed",
240
+ error: `design/boards/${FOLDERS_FILE} must be a regular file, not a symlink`,
241
+ sha256: null
242
+ };
243
+ const content = readFileSync(p, "utf8");
244
+ let raw;
245
+ try {
246
+ raw = JSON.parse(content);
247
+ } catch {
248
+ return {
249
+ state: "malformed",
250
+ error: `design/boards/${FOLDERS_FILE} is not valid JSON - fix the file`,
251
+ sha256: hash(content)
252
+ };
253
+ }
254
+ const parsed = parseFolders(raw);
255
+ if (typeof parsed === "string") return {
256
+ state: "malformed",
257
+ error: `design/boards/${FOLDERS_FILE}: ${parsed}`,
258
+ sha256: hash(content)
259
+ };
260
+ return {
261
+ state: "ok",
262
+ folders: parsed,
263
+ sha256: hash(content)
264
+ };
265
+ }
266
+ //#endregion
267
+ export { nodeExists as a, FOLDERS_FILE as c, isBoardName as d, readDescription as f, listBoardFiles as i, buildTree as l, hash as m, checkBoardsDir as n, readRegistry as o, validateWire as p, isRegularFile as r, BOARD_NAME as s, boardFields as t, flatten as u };
@@ -1,7 +1,8 @@
1
1
  import { i as ROUTE, n as NAME } from "./cli.mjs";
2
- import { l as detectHost, r as scanFrames, s as loadConfig } from "./manifest-BzxSMoDB.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DJyjmQeh.mjs";
4
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { i as listBoardFiles, l as buildTree, n as checkBoardsDir, o as readRegistry, u as flatten } from "./boards-BdG1TJwU.mjs";
3
+ import { c as detectHost, n as scanFrames, o as loadConfig } from "./manifest-CpbsqQ_v.mjs";
4
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BXyezwfN.mjs";
5
+ import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
6
  import { basename, dirname, join, sep } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { createHash, randomBytes } from "node:crypto";
@@ -148,22 +149,67 @@ function resolvePolicy(root, allBoards, boardsFlag, allBoardsFlag) {
148
149
  reveal
149
150
  };
150
151
  }
151
- /** Read every board file; returns name -> parsed json. Bad JSON fails the build loudly. */
152
+ /** Read every board file; returns name -> parsed json. Bad JSON fails the build loudly, and so
153
+ * does a board-named entry that is not a regular file (a symlink could publish JSON from
154
+ * outside the project - the build fails closed rather than skipping it quietly). */
152
155
  function readBoards(root) {
153
156
  const dir = join(root, "design", "boards");
157
+ const de = checkBoardsDir(root, dir);
158
+ if (de) throw new Error(de);
159
+ const { boards, skipped } = listBoardFiles(dir);
160
+ if (skipped.length) throw new Error(`design/boards/${skipped[0]} is not a regular file (a symlinked board cannot be published)`);
154
161
  const out = {};
155
- if (!existsSync(dir)) return out;
156
- for (const f of readdirSync(dir)) {
157
- if (!f.endsWith(".json")) continue;
158
- const name = f.replace(/\.json$/, "");
159
- try {
160
- out[name] = JSON.parse(readFileSync(join(dir, f), "utf8"));
161
- } catch {
162
- throw new Error(`design/boards/${f} is not valid JSON`);
163
- }
162
+ for (const b of boards) {
163
+ if (b.json === null) throw new Error(`design/boards/${b.name}.json is not valid JSON`);
164
+ out[b.name] = b.json;
164
165
  }
165
166
  return out;
166
167
  }
168
+ /** Switcher order = the sidebar's reading order: the folder tree over the PUBLISHED boards only
169
+ * (a folder left with no published board drops out - its name never reaches the bundle),
170
+ * flattened depth-first; all-scenes always LAST (it is the expensive everything-board, never
171
+ * the landing). `names[0]` is where `/` opens. Folder names of published boards are structure,
172
+ * like board names: they ship. */
173
+ function publishedTree(published, allBoards, folders) {
174
+ const field = (n, k) => allBoards[n]?.[k];
175
+ const tree = buildTree(published.filter((n) => n !== "all-scenes").map((n) => ({
176
+ name: n,
177
+ order: field(n, "order"),
178
+ folder: field(n, "folder")
179
+ })), folders).filter((it) => it.kind === "board" || it.boards.length > 0);
180
+ return {
181
+ tree,
182
+ names: [...flatten(tree), ...published.includes("all-scenes") ? ["all-scenes"] : []]
183
+ };
184
+ }
185
+ /** The bundle's manifest: the published frames, and descriptions of published things ONLY -
186
+ * the project's, the published scenes' (their brief path only when source is revealed),
187
+ * the published boards' and the folders those sit in. No unpublished name or sentence. */
188
+ function publishedManifest(manifest, pubFrames, publishedNames, strip) {
189
+ const pubScenes = new Set(pubFrames.map((f) => f.scene));
190
+ const pubBoardSet = new Set(publishedNames);
191
+ const pubBoards = (manifest.boards ?? []).filter((b) => pubBoardSet.has(b.name));
192
+ const pubFolderSet = new Set(pubBoards.map((b) => b.folder).filter(Boolean));
193
+ const pubFolders = (manifest.folders ?? []).filter((f) => pubFolderSet.has(f.name));
194
+ return {
195
+ ...manifest.project ? { project: manifest.project } : {},
196
+ ...pubFolders.length ? { folders: pubFolders } : {},
197
+ ...pubBoards.length ? { boards: pubBoards } : {},
198
+ scenes: manifest.scenes.filter((s) => pubScenes.has(s.name)).map(({ name, description, brief }) => ({
199
+ name,
200
+ frames: pubFrames.filter((f) => f.scene === name).length,
201
+ ...description ? { description } : {},
202
+ ...brief && !strip ? { brief } : {}
203
+ })),
204
+ frames: pubFrames
205
+ };
206
+ }
207
+ /** The folder registry: absent = no folders (boards still imply theirs); malformed fails the build. */
208
+ function readFolders(root) {
209
+ const reg = readRegistry(join(root, "design", "boards"));
210
+ if (reg.state === "malformed") throw new Error(reg.error);
211
+ return reg.folders;
212
+ }
167
213
  /**
168
214
  * The generated registry with OPAQUE keys - the source strip's half of the
169
215
  * bundle (01-sharing §6.2). Object keys are string literals that survive
@@ -267,18 +313,17 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
267
313
  const pkgDir = packageDir();
268
314
  const clientDir = join(pkgDir, "src", "client");
269
315
  const outDir = join(root, "design", ".dist");
270
- const manifest = scanFrames(root);
316
+ const manifest = scanFrames(root, {
317
+ name: config.share.name || basename(root),
318
+ description: config.description
319
+ });
271
320
  const allBoards = readBoards(root);
272
321
  const policy = resolvePolicy(root, allBoards, boardsFlag, allBoardsFlag);
273
322
  const rights = Object.fromEntries(Object.entries(policy.boards).map(([n, p]) => [n, p.max]));
274
323
  const strip = !policy.reveal.source;
275
324
  const buildSalt = randomBytes(16).toString("hex");
276
325
  const opaquePath = (id, ext) => `__mv/f/mv${createHash("sha256").update(buildSalt).update(id).digest("hex").slice(0, 12)}${ext}`;
277
- const boardOrder = (n) => {
278
- const o = allBoards[n]?.order;
279
- return typeof o === "number" && Number.isFinite(o) ? o : Infinity;
280
- };
281
- const publishedNames = Object.keys(rights).sort((a, b) => a === "all-scenes" ? 1 : b === "all-scenes" ? -1 : boardOrder(a) - boardOrder(b) || a.localeCompare(b));
326
+ const { tree, names: publishedNames } = publishedTree(Object.keys(rights), allBoards, readFolders(root));
282
327
  const includeAll = publishedNames.includes("all-scenes");
283
328
  const boards = {};
284
329
  for (const n of publishedNames) if (allBoards[n]) boards[n] = allBoards[n];
@@ -288,17 +333,10 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
288
333
  for (const b of Object.values(boards)) for (const node of Array.isArray(b?.nodes) ? b.nodes : []) if (typeof node?.frame === "string") wanted.add(node.frame);
289
334
  frames = manifest.frames.filter((f) => wanted.has(f.id));
290
335
  }
291
- const pubFrames = strip ? frames.map((f) => ({
336
+ const pubManifest = publishedManifest(manifest, strip ? frames.map((f) => ({
292
337
  ...f,
293
338
  file: opaquePath(f.id, f.kind === "html" ? ".html" : "")
294
- })) : frames;
295
- const pubManifest = {
296
- frames: pubFrames,
297
- scenes: [...new Set(pubFrames.map((f) => f.scene))].sort().map((name) => ({
298
- name,
299
- frames: pubFrames.filter((f) => f.scene === name).length
300
- }))
301
- };
339
+ })) : frames, publishedNames, strip);
302
340
  for (const n of publishedNames) {
303
341
  const pb = policy.boards[n];
304
342
  if (pb?.type !== "slides" && pb?.open !== "slides") continue;
@@ -330,6 +368,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
330
368
  manifest: pubManifest,
331
369
  boards,
332
370
  names: publishedNames,
371
+ tree,
333
372
  default: publishedNames.find((n) => n !== "all-scenes") ?? publishedNames[0],
334
373
  rights,
335
374
  policy: {
@@ -461,7 +500,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
461
500
  const realAssets = existsSync(assetsDir) ? realpathSync(assetsDir) : null;
462
501
  for (const r of refs) {
463
502
  if (!isLocalAssetRef(r) || !r.endsWith(".poster.png") || existsSync(join(assetsDir, r))) continue;
464
- const { ensurePoster } = await import("./poster-CbpzSzJu.mjs");
503
+ const { ensurePoster } = await import("./poster-DOY7pax8.mjs");
465
504
  const g = await ensurePoster(assetsDir, r.slice(0, -11));
466
505
  if (!g.ok) throw new Error(`design/assets/${r}: ${g.error}`);
467
506
  console.log(` poster: rendered design/assets/${r} (${g.width}×${g.height})`);
package/dist/cli.mjs CHANGED
@@ -53,14 +53,14 @@ function version() {
53
53
  }
54
54
  const cli = cac(NAME);
55
55
  cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "studio | embedded", { default: "studio" }).option("--no-demo", "Skip the demo scene (the demo ships unless this flag is passed)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
56
- const { init } = await import("./init-QKNi9gvF.mjs");
56
+ const { init } = await import("./init-BQIpIpIv.mjs");
57
57
  init(resolve(opts.root), {
58
58
  mode: opts.mode === "embedded" ? "embedded" : "studio",
59
59
  demo: opts.demo !== false
60
60
  });
61
61
  });
62
62
  for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot reload, comments, Live Jam)"], ["canvas", "Start the local canvas - same as dev"]]) cli.command(name, desc).option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
63
- const { dev } = await import("./dev-BxCmeU_H.mjs");
63
+ const { dev } = await import("./dev-CdcIuhZJ.mjs");
64
64
  let port;
65
65
  if (opts.port !== void 0) {
66
66
  const n = Number(opts.port);
@@ -70,7 +70,7 @@ for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot
70
70
  await dev(resolve(opts.root), port);
71
71
  });
72
72
  cli.command("build", "Static export → design/.dist (what ships comes from design/publish.json - publishing is default-closed)").option("--boards <names>", "Publish only these boards (comma-separated); overrides the publish policy").option("--all-boards", "Publish every board - the loud override for the default-closed policy").option("--embed-seeds", "Copy comment history INTO the web root (identifying - every event carries its author's email)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
73
- const { buildSite } = await import("./build-DfuTQZlY.mjs");
73
+ const { buildSite } = await import("./build-Ct0iMoSi.mjs");
74
74
  try {
75
75
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
76
76
  await buildSite(resolve(opts.root), boards, opts.allBoards === true, opts.embedSeeds === true);
@@ -89,7 +89,7 @@ cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").opti
89
89
  serve(resolve(opts.root), port);
90
90
  });
91
91
  cli.command("comments <action> [value]", "Comment collaboration: connect <url> · sync · list · reply <thread> · resolve <thread> · invite <email> · revoke <email>").option("--root <dir>", "Host repo root", { default: "." }).option("--token <token>", "connect: the canvas's MARVER_CLI_TOKEN (default $MARVER_CLI_TOKEN) - the identity-mode path").option("--invite <token>", "connect: claim this invite instead of signing in").option("--canvas-password <password>", "connect: the canvas gate password (default $MARVER_PASSWORD or prompt)").option("--email <email>", "connect: account email (skips the prompt)").option("--password <password>", "connect: account password (skips the prompt - mind your shell history)").option("--name <name>", "connect --invite: display name for the new account").option("--open", "list: only unresolved threads").option("--json", "list: machine-readable output").option("--board <board>", "scope to one board").option("--body <text>", "reply: the reply text").option("--addressed-in <frame>", "resolve: the variant frame that answered the feedback").action(async (action, value, opts) => {
92
- const { commentsCommand } = await import("./comments-DHB_8BRa.mjs");
92
+ const { commentsCommand } = await import("./comments-oYcZ3cE-.mjs");
93
93
  try {
94
94
  await commentsCommand(resolve(opts.root), action, value, opts);
95
95
  } catch (err) {
@@ -115,6 +115,15 @@ cli.command("work <action> [...frames]", "Working state on the canvas: start <sc
115
115
  process.exit(1);
116
116
  }
117
117
  });
118
+ cli.command("boards", "The sidebar as files: every folder and board in reading order, the landing board, the registry").option("--root <dir>", "Host repo root", { default: "." }).option("--json", "The tree as JSON ({ tree, landing, registry })").action(async (opts) => {
119
+ const { boardsCommand } = await import("./boards-6hKVW42a.mjs");
120
+ try {
121
+ boardsCommand(resolve(opts.root), opts);
122
+ } catch (err) {
123
+ console.error(`[${NAME}] ${err.message}`);
124
+ process.exit(1);
125
+ }
126
+ });
118
127
  cli.command("shot <frame>", "Render one frame headless and print the PNG path (needs `dev` running)").option("--root <dir>", "Host repo root", { default: "." }).option("--theme <name>", "Theme to render (default: light)").option("--scale <n>", "Device pixels per CSS px, 1-4 (default: 2; 4 for print-quality stills)").action(async (frame, opts) => {
119
128
  const { shotCommand } = await import("./shot-By1AItpD.mjs");
120
129
  try {
@@ -1,7 +1,7 @@
1
1
  import { n as replay } from "./events-B3LBn74P.mjs";
2
2
  import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
3
3
  import { n as localProfile } from "./profile-BjAPAJSb.mjs";
4
- import { i as toFrameId } from "./manifest-BzxSMoDB.mjs";
4
+ import { r as toFrameId } from "./manifest-CpbsqQ_v.mjs";
5
5
  import { workActivity } from "./work-CLrmY-vQ.mjs";
6
6
  import { r as deviceId, t as has } from "./ledger-Bu0BjqIe.mjs";
7
7
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";