@marver-design/marver 0.14.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 (42) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +7 -4
  3. package/dist/boards-6hKVW42a.mjs +57 -0
  4. package/dist/boards-BdG1TJwU.mjs +267 -0
  5. package/dist/{build-ByYafIhj.mjs → build-Ct0iMoSi.mjs} +77 -30
  6. package/dist/cli.mjs +15 -6
  7. package/dist/{daemon-Bfucyf1o.mjs → daemon-Cb4JjpgL.mjs} +1 -1
  8. package/dist/{dev-yZMyQeUj.mjs → dev-CdcIuhZJ.mjs} +14 -4
  9. package/dist/{init-Dvaso7YO.mjs → init-BQIpIpIv.mjs} +6 -3
  10. package/dist/{manifest-DvOmglFp.mjs → manifest-CpbsqQ_v.mjs} +94 -17
  11. package/dist/{plugin-BsmG5i2X.mjs → plugin-BXyezwfN.mjs} +201 -74
  12. package/dist/poster-DOY7pax8.mjs +143 -0
  13. package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
  14. package/dist/{shot-kbR_xzJH.mjs → shot-CwmHO5T4.mjs} +197 -56
  15. package/docs/live-jam.md +6 -2
  16. package/docs/publish.md +4 -1
  17. package/docs/slides.md +9 -3
  18. package/package.json +1 -1
  19. package/src/client/content/chart.tsx +62 -24
  20. package/src/client/content/index.tsx +5 -2
  21. package/src/client/content/video.tsx +132 -35
  22. package/src/client/frame-host/bridge.js +6 -1
  23. package/src/client/shell/App.tsx +33 -247
  24. package/src/client/shell/BoardList.tsx +408 -0
  25. package/src/client/shell/ContextMenu.tsx +59 -0
  26. package/src/client/shell/icons.tsx +7 -0
  27. package/src/client/shell/store.ts +156 -48
  28. package/src/client/shell/styles.css +42 -4
  29. package/src/shared/board-tree.ts +285 -0
  30. package/templates/AGENTS-embedded.md +35 -7
  31. package/templates/AGENTS-studio.md +35 -7
  32. package/templates/instructions/boards.md +120 -7
  33. package/templates/instructions/craft.md +21 -0
  34. package/templates/instructions/discover.md +7 -2
  35. package/templates/instructions/iterate.md +114 -18
  36. package/templates/instructions/jam.md +18 -2
  37. package/templates/instructions/review.md +4 -0
  38. package/templates/instructions/shape.md +16 -2
  39. package/templates/instructions/slides.md +5 -1
  40. package/templates/instructions/welcome.md +4 -1
  41. package/templates/instructions/wireframe.md +3 -0
  42. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,170 @@
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
+
80
+ ## 0.15.0 - 2026-09-02
81
+
82
+ ### Added
83
+
84
+ - **Copy frame as image.** The floating toolbar gains an images-square button
85
+ (right of Copy path): one click puts the selected frame on the clipboard as
86
+ a **2x PNG**, rendered by the dev server's headless Chrome - the same picture
87
+ `marver shot` gives an agent. `i` is the shortcut; `⇧i` (or shift-click)
88
+ renders at **4x** (a slide is 5120×2880). The icon breathes while the render
89
+ runs and flashes a check on success, like Copy path. Sized to the node: a
90
+ frame resized to Laptop copies as Laptop; content frames capture in full;
91
+ slides are always the 1280×720 artwork. Dev canvases only - a published
92
+ container has no browser - and one frame at a time.
93
+ - **`marver shot --scale <1-4>`** and `scale=`/`w=`/`h=`/`format=png` on
94
+ `/api/shot`. A frame too tall for the asked scale steps down (4 → 2 → 1)
95
+ inside a 16384px-per-side / 64M-pixel capture budget and reports it in
96
+ `note`; the file name carries the scale actually used (`…@4x.png`), so a
97
+ 4x file always holds 4x pixels.
98
+ - **Render settle.** Every shot (not only content frames) now waits - bounded
99
+ to 3 s, and again in the final viewport of a full-height capture - for fonts,
100
+ in-viewport images, LOD decodes, echarts instances and mermaid diagrams before
101
+ capturing, so a slide with a chart or a photo is never shot half-drawn. Shot
102
+ file names: a default request keeps its unsuffixed name whatever scale the
103
+ capture settled on; an explicit scale gets `@4x`, or `@4x-as-2x` when it had
104
+ to step down, so nothing an agent reads moves and `@4x` never lies.
105
+
106
+ ### Changed
107
+
108
+ - **Three standing rules for the agent, from dogfooding** (`AGENTS.md`,
109
+ `boards.md`, `iterate.md`, `jam.md` - refreshed by `marver init`):
110
+ - **One horizontal band by default.** Every curated board carries a `layout`
111
+ recipe with the scenes side by side; a second band only when the agent can
112
+ say why the eye should move down, with a gap that reads as "below". (Without
113
+ a recipe the shell stacks every scene as its own row.)
114
+ - **Look sideways.** A pin or a pasted pointer marks where the human noticed
115
+ a problem, not the only place it lives: the agent checks the board's live
116
+ sibling frames, fixes the same defect in the same pass and names the frames,
117
+ or asks in-thread whether to roll it out - never one frame fixed and its
118
+ siblings left wrong. History (`archive/`, versions) is never a sibling.
119
+ - **Version the scene before a round.** A round of feedback on a reviewed
120
+ scene starts with a snapshot, `design/scenes/<scene>-v<N>/`, pinned as its
121
+ own band on the `archive` board (oldest at the top), with `data-goto`,
122
+ `goto:` links and `meta.of` re-pointed so the version plays on its own; then
123
+ the live frames are edited in place and threads answered. One snapshot per
124
+ round, git commits per version on offer, staging only the round's paths.
125
+ Rollback is a copy back; the archive is the proof of work.
126
+
127
+ ### Fixed
128
+
129
+ - **Charts everywhere, not only on slides.** `Chart` now takes its ink and
130
+ typeface from the frame it sits in (a UI screen's Tailwind colour and font, a
131
+ Doc's tokens, a Slide's), sizes its type to the context (12px labels in a
132
+ screen or document, 18px on a slide), paints series labels in that ink with
133
+ no halo, follows the layout on resize (a chart no longer pins its flex/grid
134
+ column at mount width), and keys on the option's content so a parent
135
+ re-render never re-initialises it (a viewer's dataZoom or legend selection
136
+ survives, and a formatter edit reaches the live chart). Before this a chart
137
+ in a dark screen or dark spec drew dark-on-dark axis text at slide scale.
138
+ - **Video plays everywhere, not only on slides.** The player used to mount
139
+ only under the slides-mode contract, so a `Video` in a screen, a spec or a
140
+ published prototype was a poster forever. Now the poster is the play button
141
+ wherever the frame is live (interact, play, focus, published); slides keep
142
+ the auto-mounted player, and leaving interact mode on the canvas disarms a
143
+ playing clip so the frame goes still again. New: `ratio="9 / 16"` for
144
+ vertical clips, and `autoplay` for a muted ambient loop (an explicit choice -
145
+ that frame stays live on the canvas). The player's glyphs are Phosphor
146
+ (play, pause, speaker, corners). `shape.md` and `craft.md` teach it.
147
+ - **Posters render themselves.** A local clip without `poster` no longer fails
148
+ the build: marver renders `<clip>.poster.png` beside it from the clip's own
149
+ first moments (at 0.5 s, past the usual black opening) in the same headless
150
+ Chrome the shot renderer uses - the dev server on first sight (the frame
151
+ asks when its conventional poster is missing), `marver build` before assets
152
+ are copied, and `shot` before a frame's first capture. Posters render on
153
+ their own capture lane, so a frame's poster never waits behind the shot that
154
+ needs it. An authored poster always wins; without Chrome the build says
155
+ exactly which file to add.
156
+ - **A screen with a chart is still a screen.** Importing anything from
157
+ `@marver-design/marver/content` used to turn the frame into a content
158
+ document (spec badge, measured height, no device). Content frames are now
159
+ those that render `Doc`, `Md`, `Diagram` or `Img`; `Chart`, `Video`, `Row`,
160
+ `Col` and `Space` are shared blocks a screen or a slide uses freely. The
161
+ block card (padding, hairline, surface) is a document treatment: inside a
162
+ slide or a screen a block is bare, so charts no longer carry 32px of
163
+ invisible padding there. `shape.md` and `craft.md` now teach `Chart`.
164
+ - **Dev server behind a symlinked path.** `marver dev` on a repo reached
165
+ through a symlink (`/tmp` → `/private/tmp`, a linked Dropbox folder) refused
166
+ to serve its own frames ("outside of Vite serving allow list"); the allow
167
+ list now carries the realpath too.
168
+
5
169
  ## 0.14.0 - 2026-09-02
6
170
 
7
171
  ### Added
package/README.md CHANGED
@@ -40,11 +40,13 @@ 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.
47
47
  - **Content frames.** Specs, Mermaid diagrams, mood boards, and slides live on the same canvas as the screens - import `Doc`, `Md`, `Diagram`, `Img`, `Slide`, `Chart`, `Video` from `@marver-design/marver/content` and think a feature through before any pixels exist. Works in a repo with no app at all: idea first, design second.
48
+ - **Charts and video in any frame.** `Chart` (Apache ECharts, SVG, still at rest) inherits the ink, typeface and accent of whatever frame it sits in - a Tailwind dashboard, a dark spec, a slide - sizes its type to the context and follows the layout on resize. `Video` is poster-first everywhere: click to play wherever the frame is live, `autoplay` for an ambient loop, `ratio` for vertical clips; omit the poster and marver renders one from the clip. A screen with a chart or a clip is still a screen.
49
+ - **Copy as image.** Select a frame, press `i` - a 2x PNG of it lands on the clipboard, rendered by the same headless Chrome that serves `marver shot`; `⇧i` for 4x (a slide is 5120×2880). Paste into Slack, a doc, or a chat with your agent.
48
50
 
49
51
  ## Slides
50
52
 
@@ -82,7 +84,7 @@ The trust boundary is hard: only comments written on the owner's machine trigger
82
84
 
83
85
  ## Working state
84
86
 
85
- The same glow, driven from the terminal. When your agent takes a request, it creates the frame files first, pins them on a board, and runs `npx marver work start <scene/frame ...>` - you see the work land on the canvas in seconds, watch it shimmer while subagents build in parallel, and see it settle on `work done`. Marks self-expire, so a crashed agent never leaves a frame glowing. And `npx marver shot <scene/frame>` renders one frame headless to a PNG, so the agent can look at what it built before it says it is done.
87
+ The same glow, driven from the terminal. When your agent takes a request, it creates the frame files first, pins them on a board, and runs `npx marver work start <scene/frame ...>` - you see the work land on the canvas in seconds, watch it shimmer while subagents build in parallel, and see it settle on `work done`. Marks self-expire, so a crashed agent never leaves a frame glowing. And `npx marver shot <scene/frame> [--scale 4]` renders one frame headless to a PNG, so the agent can look at what it built before it says it is done - the same picture you get from the canvas's copy-as-image.
86
88
 
87
89
  ## Commands
88
90
 
@@ -95,7 +97,8 @@ The same glow, driven from the terminal. When your agent takes a request, it cre
95
97
  | `npx marver share …` | The roster (owner): `add <who> [--role]` · `remove` · `block` / `unblock` · `general <mode>` · `list` · `requests` · `explain <who>` · `who` |
96
98
  | `npx marver comments …` | The agent's queue: `connect <url>` · `sync` · `list` · `reply` · `resolve` · `invite <email>` · `revoke <email>` |
97
99
  | `npx marver work …` | Working glow from the terminal: `start <scene/frame …>` · `done … \| --all` · `list` |
98
- | `npx marver shot <frame>` | Render one frame headless and print the PNG path (needs `dev` running) |
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 |
99
102
 
100
103
  ## Shortcuts
101
104
 
@@ -108,7 +111,7 @@ The same glow, driven from the terminal. When your agent takes a request, it cre
108
111
 
109
112
  **Board & chrome** - `t` tidy · `d` toggle light/dark for the board · `⌘\` (ctrl+\) collapse/open sidebar.
110
113
 
111
- **Selection** - click selects · shift+click (canvas or sidebar) builds a multi-selection · `⌘A` selects every frame on the board · `⇧P` copies the selected frames' paths (board, frame, and file) · double-click enters interact mode (`esc` or click outside leaves) · drag the title bar to move, edges to resize (widths snap to devices).
114
+ **Selection** - click selects · shift+click (canvas or sidebar) builds a multi-selection · `⌘A` selects every frame on the board · `⇧P` copies the selected frames' paths (board, frame, and file) · `i` copies the selected frame to the clipboard as a 2x PNG (`⇧i` for 4x; also the images-square button in the floating toolbar - dev canvases only, the renderer is the dev server's headless Chrome) · double-click enters interact mode (`esc` or click outside leaves) · drag the title bar to move, edges to resize (widths snap to devices).
112
115
 
113
116
  **Modes** - `c` comment mode · `l` laser mode · `⇧C` hide/show comment pins · `⇧L` laser comment (spotlight a thread's element) · `p` play (present, or slides on a slides board) · `h` hide all chrome.
114
117
 
@@ -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 };