@marver-design/marver 0.15.0 → 0.16.1

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 (36) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +2 -1
  3. package/dist/boards-BmxcT3Lc.mjs +290 -0
  4. package/dist/boards-PuVzw5Wp.mjs +62 -0
  5. package/dist/{build-DfuTQZlY.mjs → build-B4yPgFNF.mjs} +72 -29
  6. package/dist/cli.mjs +33 -13
  7. package/dist/{daemon-DalgvoA9.mjs → daemon-Bbh_jmui.mjs} +1 -1
  8. package/dist/{dev-BxCmeU_H.mjs → dev-DH2W7Ffw.mjs} +4 -4
  9. package/dist/{init-QKNi9gvF.mjs → init-B7YhcN2o.mjs} +6 -3
  10. package/dist/{manifest-BzxSMoDB.mjs → manifest-CaslQIAO.mjs} +175 -12
  11. package/dist/{plugin-DJyjmQeh.mjs → plugin-BeBGu3gH.mjs} +239 -82
  12. package/dist/{poster-CbpzSzJu.mjs → poster-CoyobbGW.mjs} +1 -1
  13. package/dist/{shot-BWhoz6cU.mjs → shot-z-d-zMzf.mjs} +2 -2
  14. package/docs/publish.md +4 -1
  15. package/package.json +1 -1
  16. package/src/client/shell/App.tsx +45 -259
  17. package/src/client/shell/BoardList.tsx +438 -0
  18. package/src/client/shell/ContextMenu.tsx +59 -0
  19. package/src/client/shell/LockedApp.tsx +2 -1
  20. package/src/client/shell/Play.tsx +1 -0
  21. package/src/client/shell/icons.tsx +6 -0
  22. package/src/client/shell/labels.ts +4 -4
  23. package/src/client/shell/store.ts +156 -51
  24. package/src/client/shell/styles.css +40 -4
  25. package/src/shared/board-tree.ts +343 -0
  26. package/templates/AGENTS-embedded.md +25 -5
  27. package/templates/AGENTS-studio.md +25 -5
  28. package/templates/instructions/boards.md +91 -2
  29. package/templates/instructions/craft.md +4 -0
  30. package/templates/instructions/discover.md +20 -3
  31. package/templates/instructions/iterate.md +5 -4
  32. package/templates/instructions/review.md +4 -0
  33. package/templates/instructions/shape.md +1 -1
  34. package/templates/instructions/welcome.md +4 -1
  35. package/templates/instructions/wireframe.md +3 -0
  36. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,107 @@
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.1 - 2026-09-03
6
+
7
+ ### Added
8
+
9
+ - **Titles.** Boards, folders and scenes can be called anything - "MVP", "UI",
10
+ "Checkout (v2) 🛒". Rename in the sidebar (scenes too, now) edits a `title`;
11
+ the file name, folder key and scene directory stay the slug agents address.
12
+ No title = the Title-Cased slug, as before. Stored on the board JSON, the
13
+ `_folders.json` entry and the brief's YAML front matter; shown everywhere the
14
+ object is named and carried by the manifest and `npx marver boards`.
15
+
16
+ ### Fixed
17
+
18
+ - **Drops land where the seam shows.** A board grabbed by its icon and dragged
19
+ between two folder boards landed after the folder; releases in blank space did
20
+ nothing. The resolver now reads the pointer against the rendered rows, the
21
+ release applies the seam on screen, and a release outside the panel cancels.
22
+ - A project reached through a symlink (macOS `/var`, a linked projects folder)
23
+ lost its live updates; the CLI now resolves the root's real path.
24
+
25
+ ### Changed
26
+
27
+ - `boards/rename` takes `{ from, to?, title?, baseHash? }` (title-only, CAS);
28
+ new `scenes/rename { scene, title }`; `sh:scenes` relabels scenes without
29
+ re-keying iframes.
30
+
31
+ ## 0.16.0 - 2026-09-03
32
+
33
+ ### Added
34
+
35
+ - **Board folders.** The sidebar's boards can live in folders, one level deep.
36
+ Right-click the Boards header (or its `+`) for a new folder and name it
37
+ inline - what you type becomes a slug ("Old stuff" → `old-stuff`, shown "Old
38
+ Stuff"); right-click a board for **Move to new folder** (the folder takes the
39
+ board's slot, the input has focus) or **Move to top level**; right-click a
40
+ folder to **Rename** or **Delete** it (its boards go back to the top level -
41
+ folders organise, never own). Moving into an existing folder is a **drag**:
42
+ the board drag-and-drop now lands boards inside folders (drop on the folder
43
+ row), in any slot inside one, back out to the root (or the left gutter of a
44
+ folder's rows), and drags folders among boards. A click collapses a folder; the
45
+ choice is remembered per browser. The folder holding the active board keeps
46
+ the ancestor wash so its home stays visible collapsed.
47
+ - **Folders are files, for agents too.** `"folder": "<name>"` on a board file
48
+ puts it in a folder (`order` ranks it among its folder siblings);
49
+ `design/boards/_folders.json` names empty folders and ranks folders at the
50
+ root. A folder two boards name exists without the registry. The landing
51
+ board is the first board in sidebar order, folders included. **`npx marver
52
+ boards`** prints the sidebar as the files say it is (folders, boards in
53
+ reading order with their `order`, the landing board; `--json` for the tree),
54
+ so an agent looks before it organises. The agent contract and
55
+ `instructions/boards.md` carry the grammar, every move (create, file in,
56
+ move out, rank, rename, delete) as a file edit, and a nudge to group
57
+ proactively past six or eight boards.
58
+ - **Published bundles** carry the folder tree of the published boards only; a
59
+ folder with nothing published never reaches the bundle. The published
60
+ sidebar shows folders read-only.
61
+ - **Descriptions - purpose notes on every object, for agents.** One optional
62
+ `description` (one sentence: what it is for, its state) on the project
63
+ (`description` in `design/config.ts`), a board (its JSON - preserved by
64
+ autosave and by sidebar drags like `order`/`folder`), a folder (its
65
+ `_folders.json` entry - it rides along through renames), a scene (the first
66
+ non-blank line of its `_brief.md`, `#` stripped, front matter skipped - no
67
+ new file) and a frame
68
+ (`meta.description`). `design/manifest.json` becomes the orientation file it
69
+ was meant to be: `project`, `folders`, `boards` (sidebar order, with folder
70
+ and description), `scenes` (with description and brief path) and `frames`
71
+ (with description). `marver dev` regenerates it on board and brief edits too,
72
+ and broadcasts `sh:manifest` only when the frames changed - a description
73
+ edit never re-keys the live iframes. Published bundles ship descriptions of
74
+ published things only (brief paths only with source revealed). `marver
75
+ boards` prints them. Editing `description` in `design/config.ts` under `dev`
76
+ refreshes the manifest live (the rest of the config still needs a restart).
77
+ The agent contract teaches: write it at creation, keep it true, fix what your
78
+ session made false before it ends (the review walk ends on it). Nothing
79
+ renders in the canvas.
80
+ - `export const meta` picks a literal even when the prose holds the other
81
+ quote (`"the buyer's path"`), and refuses a computed value (`"Draft" + phase`)
82
+ instead of taking its literal half.
83
+
84
+ ### Changed
85
+
86
+ - `POST /__mv/api/boards/reorder` takes the WHOLE tree (`{ tree, base }`) in
87
+ place of `{ order }`: root boards as strings, folders as `{ folder, boards }`,
88
+ plus the sha256 the client last saw for every board it names and for the
89
+ registry. The server preflights every named board (present, a regular file,
90
+ well-formed, unchanged) BEFORE writing anything - a 409 names the stale
91
+ boards and the shell re-reads and replays its move once, so an agent's
92
+ concurrent `folder` edit and a human's drag can never silently erase each
93
+ other. It answers the new hashes, and the shell's autosave of the active
94
+ board keeps its CAS token current - a reorder no longer reboots the board
95
+ you are editing. `GET /__mv/api/boards` gains `folder`; `GET
96
+ /__mv/api/folders` is new.
97
+ - Board enumeration (dev API, build, `--all-boards`) counts only regular files
98
+ on the board-name grammar: `_folders.json`, temp files and symlinks are never
99
+ boards - the build fails closed on a symlinked board. A malformed
100
+ `_folders.json` is a 422 the sidebar toasts (and a build error), never a
101
+ silently empty registry the next drag would overwrite.
102
+ - The sidebar re-reads its list on a coalesced `sh:boards` broadcast (any add,
103
+ write or delete under `design/boards/`), so agent-written boards and folders
104
+ show in under half a second instead of the 8 s poll.
105
+
5
106
  ## 0.15.0 - 2026-09-02
6
107
 
7
108
  ### 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 (the seam shows exactly where a release lands) 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). Boards, folders and scenes take a `title` - any name you like, "MVP", "UI", "Checkout (v2) 🛒" - while their file, key and directory stay the slug agents address; Rename in the sidebar edits the title. 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 their title, `order` and description, the landing board |
101
102
 
102
103
  ## Shortcuts
103
104
 
@@ -0,0 +1,290 @@
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 readTitle = (v) => {
29
+ if (typeof v !== "string") return void 0;
30
+ return Array.from(v.replace(/[\u0000-\u001f\u007f]/g, " ").trim().replace(/\s+/g, " ")).slice(0, 120).join("").trim() || void 0;
31
+ };
32
+ const rank = (o) => typeof o === "number" && Number.isFinite(o) ? o : Infinity;
33
+ /** A folder's title and description, present only when set. */
34
+ const folderExtras = (it) => ({
35
+ ...it.title ? { title: it.title } : {},
36
+ ...it.description ? { description: it.description } : {}
37
+ });
38
+ /** 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
+ function parseFolders(raw) {
42
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return "expected an object";
43
+ const { version, folders } = raw;
44
+ if (version !== void 0 && version !== 1) return `unsupported version ${String(version)}`;
45
+ if (!Array.isArray(folders)) return "expected a \"folders\" array";
46
+ const out = [];
47
+ const seen = /* @__PURE__ */ new Set();
48
+ for (const f of folders) {
49
+ const name = f?.name;
50
+ if (!isBoardName(name)) return "a folder needs a name - lowercase letters, numbers and dashes";
51
+ if (seen.has(name)) return `folder "${name}" is listed twice`;
52
+ seen.add(name);
53
+ const o = f.order;
54
+ const t = readTitle(f.title);
55
+ const d = readDescription(f.description);
56
+ out.push({
57
+ name,
58
+ ...typeof o === "number" && Number.isFinite(o) ? { order: o } : {},
59
+ ...t ? { title: t } : {},
60
+ ...d ? { description: d } : {}
61
+ });
62
+ }
63
+ return out;
64
+ }
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. */
70
+ 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
+ }
77
+ const members = /* @__PURE__ */ new Map();
78
+ const rootBoards = [];
79
+ for (const b of boards) {
80
+ if (!isBoardName(b.name) || b.name === "all-scenes") continue;
81
+ const folder = isBoardName(b.folder) ? b.folder : void 0;
82
+ if (!folder) {
83
+ rootBoards.push(b);
84
+ continue;
85
+ }
86
+ if (!folderOrder.has(folder)) folderOrder.set(folder, void 0);
87
+ const list = members.get(folder) ?? [];
88
+ list.push(b);
89
+ members.set(folder, list);
90
+ }
91
+ const byRank = (a, b) => rank(a.order) - rank(b.order) || a.name.localeCompare(b.name);
92
+ const root = [...rootBoards.map((b) => ({
93
+ item: {
94
+ kind: "board",
95
+ name: b.name
96
+ },
97
+ order: b.order
98
+ })), ...[...folderOrder].map(([name, order]) => ({
99
+ item: {
100
+ kind: "folder",
101
+ name,
102
+ boards: (members.get(name) ?? []).sort(byRank).map((b) => b.name),
103
+ ...folderMeta.get(name) ?? {}
104
+ },
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);
109
+ }
110
+ /** Every board in reading order - the order the switchers and the landing pick use. */
111
+ function flatten(tree) {
112
+ const out = [];
113
+ for (const it of tree) if (it.kind === "board") out.push(it.name);
114
+ else out.push(...it.boards);
115
+ return out;
116
+ }
117
+ function validateWire(wire) {
118
+ if (!Array.isArray(wire)) return "invalid tree";
119
+ const boards = /* @__PURE__ */ new Set(), folders = /* @__PURE__ */ new Set();
120
+ const board = (n) => {
121
+ if (!isBoardName(n) || n === "all-scenes") return "invalid board name in tree";
122
+ if (boards.has(n)) return `board "${n}" appears twice`;
123
+ boards.add(n);
124
+ return null;
125
+ };
126
+ for (const w of wire) {
127
+ if (typeof w === "string") {
128
+ const e = board(w);
129
+ if (e) return e;
130
+ continue;
131
+ }
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
+ }
145
+ if (boards.size > 200 || folders.size > 50) return "tree too large";
146
+ return null;
147
+ }
148
+ //#endregion
149
+ //#region src/server/boards.ts
150
+ /**
151
+ * Reading design/boards/ safely - the one enumerator the dev API, the build and the tests
152
+ * share. Only REGULAR files on the board-name grammar count as boards (a symlink, dangling or
153
+ * live, could read or publish JSON from outside the project - it is skipped, and reported so
154
+ * the build can fail closed). The folder registry is read the same way: absent = no folders,
155
+ * malformed = an error the human must fix, never a silently empty registry.
156
+ */
157
+ /** Does realpath(dir) stay inside realpath(root)? A symlinked design/boards can't escape. */
158
+ function underRoot(root, dir) {
159
+ try {
160
+ const rr = realpathSync(root);
161
+ const rd = realpathSync(dir);
162
+ return rd === rr || rd.startsWith(rr + sep);
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+ /** Is design/boards a directory we may read and write? It must not be a symlink at all (a
168
+ * link to the repo root would list package.json as a board and let a tree write rewrite
169
+ * it; a link outside would publish foreign JSON) and must resolve inside the root. Absent
170
+ * is fine (no boards yet). Returns the error, or null. */
171
+ function checkBoardsDir(root, boardsDir) {
172
+ return checkRealDirs(root, [[join(boardsDir, ".."), "design"], [boardsDir, "design/boards"]]);
173
+ }
174
+ /** Every EXISTING path in `dirs` (root-down order) must be a real directory inside the root -
175
+ * a symlinked `design` with no boards dir yet would otherwise be followed by the mkdir that
176
+ * creates it; a symlinked `design/scenes` would let a brief write land outside the project.
177
+ * An absent one ends the walk (nothing beneath it exists either). Returns the error, or null. */
178
+ function checkRealDirs(root, dirs) {
179
+ for (const [p, label] of dirs) {
180
+ try {
181
+ if (lstatSync(p).isSymbolicLink()) return `${label} must be a real directory, not a symlink`;
182
+ } catch {
183
+ return null;
184
+ }
185
+ if (!underRoot(root, p)) return `${label} escapes the project`;
186
+ }
187
+ return null;
188
+ }
189
+ /** A regular file (lstat: a symlink is never followed, a dangling one is not "absent"). */
190
+ const isRegularFile = (p) => {
191
+ try {
192
+ return lstatSync(p).isFile();
193
+ } catch {
194
+ return false;
195
+ }
196
+ };
197
+ /** Is there ANY node at p (a dangling symlink counts)? */
198
+ const nodeExists = (p) => {
199
+ try {
200
+ lstatSync(p);
201
+ return true;
202
+ } catch {
203
+ return false;
204
+ }
205
+ };
206
+ /** Every board file: name, raw content, hash, and its JSON (null when malformed). `skipped`
207
+ * names the entries that looked like boards but were not regular files. */
208
+ function listBoardFiles(boardsDir) {
209
+ const boards = [], skipped = [];
210
+ if (!existsSync(boardsDir)) return {
211
+ boards,
212
+ skipped
213
+ };
214
+ for (const f of readdirSync(boardsDir)) {
215
+ if (!isBoardFile(f)) continue;
216
+ const file = join(boardsDir, f);
217
+ if (!isRegularFile(file)) {
218
+ skipped.push(f);
219
+ continue;
220
+ }
221
+ const content = readFileSync(file, "utf8");
222
+ let json = null;
223
+ try {
224
+ json = JSON.parse(content);
225
+ } catch {}
226
+ boards.push({
227
+ name: f.slice(0, -5),
228
+ file,
229
+ content,
230
+ sha256: hash(content),
231
+ json
232
+ });
233
+ }
234
+ return {
235
+ boards,
236
+ skipped
237
+ };
238
+ }
239
+ /** The author-owned sidebar fields off a board's JSON, leniently: rank, folder, the title
240
+ * humans see, the sentence agents read. */
241
+ function boardFields(json, validName) {
242
+ const o = json;
243
+ const title = readTitle(o?.title);
244
+ const description = readDescription(o?.description);
245
+ return {
246
+ ...typeof o?.order === "number" && Number.isFinite(o.order) ? { order: o.order } : {},
247
+ ...validName(o?.folder) ? { folder: o.folder } : {},
248
+ ...title ? { title } : {},
249
+ ...description ? { description } : {}
250
+ };
251
+ }
252
+ /** The folder registry. `sha256` is the CAS token a tree write must echo (null = "there was
253
+ * no file"), so a write can never silently replace a registry it never saw. */
254
+ function readRegistry(boardsDir) {
255
+ const p = join(boardsDir, FOLDERS_FILE);
256
+ if (!nodeExists(p)) return {
257
+ state: "absent",
258
+ folders: [],
259
+ sha256: null
260
+ };
261
+ if (!isRegularFile(p)) return {
262
+ state: "malformed",
263
+ error: `design/boards/${FOLDERS_FILE} must be a regular file, not a symlink`,
264
+ sha256: null
265
+ };
266
+ const content = readFileSync(p, "utf8");
267
+ let raw;
268
+ try {
269
+ raw = JSON.parse(content);
270
+ } catch {
271
+ return {
272
+ state: "malformed",
273
+ error: `design/boards/${FOLDERS_FILE} is not valid JSON - fix the file`,
274
+ sha256: hash(content)
275
+ };
276
+ }
277
+ const parsed = parseFolders(raw);
278
+ if (typeof parsed === "string") return {
279
+ state: "malformed",
280
+ error: `design/boards/${FOLDERS_FILE}: ${parsed}`,
281
+ sha256: hash(content)
282
+ };
283
+ return {
284
+ state: "ok",
285
+ folders: parsed,
286
+ sha256: hash(content)
287
+ };
288
+ }
289
+ //#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 };
@@ -0,0 +1,62 @@
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";
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 title = (t) => t ? ` "${t}"` : "";
41
+ const desc = (d) => d ? ` - ${d}` : "";
42
+ const boardLine = (n) => {
43
+ const r = rows.find((x) => x.name === n);
44
+ return `${n}${title(r?.title)}${order(n)}${desc(r?.description)}`;
45
+ };
46
+ for (const it of tree) {
47
+ if (it.kind === "board") {
48
+ console.log(boardLine(it.name));
49
+ continue;
50
+ }
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
+ }
55
+ if (hasAll) console.log("all-scenes (auto, always last)");
56
+ const landing = flatten(tree)[0];
57
+ if (landing) console.log(`\nlanding board: ${landing}`);
58
+ console.log(`registry: ${reg.state === "ok" ? "design/boards/_folders.json" : "none (no empty or ranked folders yet)"}`);
59
+ if (skipped.length) console.log(`skipped (not regular files): ${skipped.join(", ")}`);
60
+ }
61
+ //#endregion
62
+ export { boardsCommand };
@@ -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 { 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 { l as detectHost, n as scanFrames, s as loadConfig } from "./manifest-CaslQIAO.mjs";
4
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BeBGu3gH.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,66 @@ 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 tree = buildTree(published.filter((n) => n !== "all-scenes").map((n) => ({
175
+ name: n,
176
+ ...boardFields(allBoards[n], isBoardName)
177
+ })), folders).filter((it) => it.kind === "board" || it.boards.length > 0);
178
+ return {
179
+ tree,
180
+ names: [...flatten(tree), ...published.includes("all-scenes") ? ["all-scenes"] : []]
181
+ };
182
+ }
183
+ /** The bundle's manifest: the published frames, and descriptions of published things ONLY -
184
+ * the project's, the published scenes' (their brief path only when source is revealed),
185
+ * the published boards' and the folders those sit in. No unpublished name or sentence. */
186
+ function publishedManifest(manifest, pubFrames, publishedNames, strip) {
187
+ const pubScenes = new Set(pubFrames.map((f) => f.scene));
188
+ const pubBoardSet = new Set(publishedNames);
189
+ const pubBoards = (manifest.boards ?? []).filter((b) => pubBoardSet.has(b.name));
190
+ const pubFolderSet = new Set(pubBoards.map((b) => b.folder).filter(Boolean));
191
+ const pubFolders = (manifest.folders ?? []).filter((f) => pubFolderSet.has(f.name));
192
+ return {
193
+ ...manifest.project ? { project: manifest.project } : {},
194
+ ...pubFolders.length ? { folders: pubFolders } : {},
195
+ ...pubBoards.length ? { boards: pubBoards } : {},
196
+ scenes: manifest.scenes.filter((s) => pubScenes.has(s.name)).map(({ name, title, description, brief }) => ({
197
+ name,
198
+ frames: pubFrames.filter((f) => f.scene === name).length,
199
+ ...title ? { title } : {},
200
+ ...description ? { description } : {},
201
+ ...brief && !strip ? { brief } : {}
202
+ })),
203
+ frames: pubFrames
204
+ };
205
+ }
206
+ /** The folder registry: absent = no folders (boards still imply theirs); malformed fails the build. */
207
+ function readFolders(root) {
208
+ const reg = readRegistry(join(root, "design", "boards"));
209
+ if (reg.state === "malformed") throw new Error(reg.error);
210
+ return reg.folders;
211
+ }
167
212
  /**
168
213
  * The generated registry with OPAQUE keys - the source strip's half of the
169
214
  * bundle (01-sharing §6.2). Object keys are string literals that survive
@@ -267,18 +312,17 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
267
312
  const pkgDir = packageDir();
268
313
  const clientDir = join(pkgDir, "src", "client");
269
314
  const outDir = join(root, "design", ".dist");
270
- const manifest = scanFrames(root);
315
+ const manifest = scanFrames(root, {
316
+ name: config.share.name || basename(root),
317
+ description: config.description
318
+ });
271
319
  const allBoards = readBoards(root);
272
320
  const policy = resolvePolicy(root, allBoards, boardsFlag, allBoardsFlag);
273
321
  const rights = Object.fromEntries(Object.entries(policy.boards).map(([n, p]) => [n, p.max]));
274
322
  const strip = !policy.reveal.source;
275
323
  const buildSalt = randomBytes(16).toString("hex");
276
324
  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));
325
+ const { tree, names: publishedNames } = publishedTree(Object.keys(rights), allBoards, readFolders(root));
282
326
  const includeAll = publishedNames.includes("all-scenes");
283
327
  const boards = {};
284
328
  for (const n of publishedNames) if (allBoards[n]) boards[n] = allBoards[n];
@@ -288,17 +332,10 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
288
332
  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
333
  frames = manifest.frames.filter((f) => wanted.has(f.id));
290
334
  }
291
- const pubFrames = strip ? frames.map((f) => ({
335
+ const pubManifest = publishedManifest(manifest, strip ? frames.map((f) => ({
292
336
  ...f,
293
337
  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
- };
338
+ })) : frames, publishedNames, strip);
302
339
  for (const n of publishedNames) {
303
340
  const pb = policy.boards[n];
304
341
  if (pb?.type !== "slides" && pb?.open !== "slides") continue;
@@ -326,10 +363,16 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
326
363
  "slides"
327
364
  ].includes(p.open);
328
365
  });
366
+ const titles = Object.fromEntries(publishedNames.flatMap((n) => {
367
+ const t = boardFields(allBoards[n], isBoardName).title;
368
+ return t ? [[n, t]] : [];
369
+ }));
329
370
  const data = {
330
371
  manifest: pubManifest,
331
372
  boards,
332
373
  names: publishedNames,
374
+ tree,
375
+ ...Object.keys(titles).length ? { titles } : {},
333
376
  default: publishedNames.find((n) => n !== "all-scenes") ?? publishedNames[0],
334
377
  rights,
335
378
  policy: {
@@ -461,7 +504,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds) {
461
504
  const realAssets = existsSync(assetsDir) ? realpathSync(assetsDir) : null;
462
505
  for (const r of refs) {
463
506
  if (!isLocalAssetRef(r) || !r.endsWith(".poster.png") || existsSync(join(assetsDir, r))) continue;
464
- const { ensurePoster } = await import("./poster-CbpzSzJu.mjs");
507
+ const { ensurePoster } = await import("./poster-CoyobbGW.mjs");
465
508
  const g = await ensurePoster(assetsDir, r.slice(0, -11));
466
509
  if (!g.ok) throw new Error(`design/assets/${r}: ${g.error}`);
467
510
  console.log(` poster: rendered design/assets/${r} (${g.width}×${g.height})`);