@marver-design/marver 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,101 @@
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.4.0 - 2026-08-14
6
+
7
+ The collaboration release (SPEC-M3): the canvas becomes a place where colleagues,
8
+ the designer, and the coding agent close the feedback loop together.
9
+
10
+ ### Added
11
+
12
+ - **Element-anchored comments.** `C` enters comment mode: click any element inside a
13
+ frame (laser outlines guide you) and the thread pins to it - fractional position
14
+ inside the element's box, so pins ride through responsive reflow. Threads are
15
+ Google-Docs-shaped: root + one level of replies, resolve/reopen. Anchors survive
16
+ agent edits via a verified ladder (semantics → CSS path → fuzzy quote match);
17
+ a dead anchor parks the thread visibly at the frame edge, never silently deleted.
18
+ Inactive frames collapse their open threads into a top-right avatar stack.
19
+ `Shift+C` hides all pins; `?c=<thread>` deep-links a specific thread through the
20
+ password gate (copy-link on every card).
21
+ - **Real commenter identity, no email infrastructure.** Viewers on a published canvas
22
+ claim an account through a single-use invite link (owner mints it; the link travels
23
+ over Slack/DM) - display name, password, avatar. Accounts are scrypt-verified with
24
+ per-user salts; sessions survive restarts. The first account owns the canvas;
25
+ `MARVER_OWNER_EMAIL` prints the owner's one-time claim link in the deploy logs.
26
+ Avatars fall back to initials on a deterministic color.
27
+ - **Gate v2: one credential per persona.** The gate on a collaboration canvas has
28
+ three doors: guests pay the canvas password (read-only), members sign in with
29
+ their OWN password (their session IS gate passage - they never touch the shared
30
+ secret), and an invite link (`<url>/#/i/<token>`) opens straight into the claim -
31
+ email shown as an INVITED chip, profile-picture picker (client-side 128px
32
+ downscale), display name, password. Sign-in/claim endpoints sit in front of the
33
+ gate (rate-limited, non-enumerating); rotating the canvas password only ever
34
+ affects guests. Primary buttons stay disabled until their mandatory fields are
35
+ filled, with a tooltip naming what's missing. The boards payload carries the
36
+ owner's display name, so a read-only refusal says who to ask. Static canvases
37
+ keep the single-field gate untouched.
38
+ - **One deploy, comments live everywhere.** `marver serve` grows a collaboration API
39
+ (REST + SSE) when `MARVER_DATA_DIR` names a durable volume; the published canvas is
40
+ the comments' home. `marver dev` two-way syncs every 30s (`marver comments connect
41
+ <url>` once), so client feedback lands in `design/comments/<board>.jsonl` - a
42
+ git-tracked, append-only event log the agent reads with zero tooling. Republishing
43
+ never clobbers collected feedback (the store unions the bundle seed on boot).
44
+ - **The agent works the queue.** `marver comments list --open --json` / `reply` /
45
+ `resolve --addressed-in <frame>` - resolving records which variant answered the
46
+ feedback, making the fork-don't-overwrite doctrine auditable. `marver comments
47
+ invite <email>` mints single-use invite links from the CLI (owner only), `revoke
48
+ <email>` retires an account. `instructions/iterate.md` carries the
49
+ comments-as-work-queue discipline; `instructions/publish.md` is the agent-facing
50
+ deploy runbook (boards policy, gate, volume, accounts) and AGENTS.md routes to it.
51
+ - **Laser mode.** `L` (or the toolbar crosshair) outlines every element in every frame
52
+ with depth-hued borders (60° per nesting level) plus a DevTools-style hover label.
53
+ Zero layout shift - outlines only. Clicking an element copies its full address for
54
+ the agent - frame source file + CSS path (+ JSX source location when stamped).
55
+ Comment mode shows only the hover highlight (no full rainbow) and a chat-teardrop
56
+ cursor, so picking an element to comment on stays calm.
57
+ - **Default-closed publishing.** `marver build` now requires a publish policy:
58
+ `design/publish.json` names each published board with `read` or `comment` rights
59
+ (enforced server-side, not just hidden in UI). `--boards a,b` stays as an explicit
60
+ override; publishing everything takes a deliberate `--all-boards`.
61
+
62
+ ### Changed
63
+
64
+ - **`c` is comment mode now** (the Figma/Miro convention). Copy-selected-file-paths
65
+ moved from `c` to `y`.
66
+ - `marver build` without a publish policy fails with instructions instead of
67
+ publishing everything - the privacy default flipped closed.
68
+
69
+ ### Security
70
+
71
+ - Comment mutations are CSRF-protected (double-submit cookie + origin checks) and
72
+ rate-limited; session and invite tokens are stored hashed; every pushed event is
73
+ validated hard (author must match the session, thread ids cannot be hijacked,
74
+ edits are owner-only, timestamps cannot come from the future) - and accepted
75
+ events are never rewritten, so id-keyed sync converges byte-identically.
76
+ - v1 trust boundary, stated plainly: frame code in your design repo runs same-origin
77
+ with the shell - review what you merge. Full frame sandboxing is the v1.1 follow-up
78
+ (SPEC-M3 §0 records the probe results and the plan).
79
+
80
+ ### Durability & concurrency
81
+
82
+ - Comment appends and auth writes are `fsync`'d before they are acknowledged - a
83
+ comment or account confirmed to the client survives a crash or volume interruption,
84
+ not just the page cache.
85
+ - `auth.json`'s read-modify-write is guarded by a cross-process lock (stale-stolen
86
+ after 10s, never deadlocks) so a deploy overlap or an accidental second instance
87
+ cannot resurrect a revoked account or drop an invite. The comment log needs no lock:
88
+ append-only + id-keyed union is conflict-free by construction (and git merges it
89
+ with `merge=union`). Two honest v1 limits, documented not hidden: thread ordering
90
+ uses client timestamps (a badly-skewed clock can mis-order a resolve/reopen race -
91
+ re-resolve to fix), and comment logs have no hard size ceiling (fine at the design-
92
+ review scale marver targets; a per-board cap is a later concern).
93
+
94
+ ## 0.3.1 - 2026-08-13
95
+
96
+ ### Fixed
97
+
98
+ - Play-mode variant chrome: the current variant's name was invisible (theme ink on the always-dark glass pill - now the pill's own light ink), and switching variants jiggled the controls. Redesigned: the letter chips now use the sidebar's exact badge language (20px, 7px-radius, one shape in every state) so the two surfaces read as one concept, they LEAD the cluster as the stable anchor, and the name trails at natural width (capped with ellipsis + a tooltip carrying the full title) - a name change grows the pill rightward without moving anything underfoot.
99
+
5
100
  ## 0.3.0 - 2026-08-13
6
101
 
7
102
  The co-thinking release: the canvas now holds the thinking, not just the screens.
package/README.md CHANGED
@@ -19,6 +19,8 @@ Then, to your agent:
19
19
  - **Devices view**: the Devices menu (or hotkeys `1`-`5`) sizes every frame to mobile / tablet / laptop / monitor / tv to sweep your breakpoints; `0` restores your own layout exactly. Widths live in `design/config.ts`.
20
20
  - `data-goto="scene/frame"` on any element links frames into a walkable prototype.
21
21
  - **Content frames**: specs, Mermaid diagrams, and mood boards live on the same canvas as the screens - import `Doc`, `Md`, `Diagram`, `Img` from `@marver-design/marver/content` and think a feature through *before* any pixels exist. Diagrams ship pre-themed (both modes), content frames auto-size to their content, and everything - devices, play mode, publish - works on them identically. Works in a repo with no app at all: idea first, design second.
22
+ - **Comments**: Google-Docs-style feedback, pinned to actual elements. Press `C`, click a div inside a frame, write - the thread lives on that element, survives edits via a layered anchor (source semantics → structure → fuzzy text), and collapses to an avatar stack when the frame isn't active. Viewers on a published canvas comment with real names and avatars (invite-link accounts, no email infrastructure); `marver dev` syncs the same threads into `design/comments/*.jsonl`, where your agent works the queue: `npx marver comments list --open --json` → fork a variant → `resolve --addressed-in`. Live via SSE; one deploy, no extra services (set `MARVER_DATA_DIR` on a volume + `MARVER_OWNER_EMAIL` for the first account).
23
+ - **Laser mode**: `L` outlines every element in every frame with depth-hued borders plus a hover label - the fastest way to see structure. Click any element to copy its full address (frame file + CSS path) for the agent. Comment mode is the calm cousin - one at a time with laser - showing only the hovered element so picking a comment target never overwhelms.
22
24
  - **Upgrade**: `npm i -D @marver-design/marver@latest && npx marver init`. The canvas tells you when a new version is out (one anonymous registry check per day, cached in `design/.local/`; `MARVER_NO_UPDATE_CHECK=1` disables). Re-running init refreshes the managed files (AGENTS.md, `design/instructions/`) - your edits to them are detected and preserved; when both you and a release changed a file, the fresh version is staged at `design/.local/latest/` for you (or your agent) to merge. Everything else in `design/` is yours and never touched.
23
25
  - Uninstall: delete `design/`, remove the dependency. (If `init` patched your tsconfig `exclude`, revert that one line.)
24
26
 
@@ -0,0 +1,245 @@
1
+ import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
4
+ //#region src/server/auth.ts
5
+ /**
6
+ * Accounts, invites, sessions (SPEC-M3 §3) - the minimal credible implementation,
7
+ * extending the gate's own idiom. No framework: per-user salted scrypt verifiers,
8
+ * opaque session tokens stored hashed, single-use invite links as the identity
9
+ * bootstrap (no email infrastructure anywhere).
10
+ *
11
+ * Password = read, account = comment: everything here concerns accounts only; the
12
+ * shared gate password (serve.ts) remains the outer READ boundary.
13
+ *
14
+ * State lives in MARVER_DATA_DIR as two small JSON files rewritten atomically -
15
+ * users change rarely; the event-log treatment is reserved for comments.
16
+ */
17
+ const SCRYPT = {
18
+ N: 2 ** 15,
19
+ r: 8,
20
+ p: 1,
21
+ keylen: 32,
22
+ maxmem: 67108864
23
+ };
24
+ const INVITE_TTL = 6048e5;
25
+ const SESSION_TTL = 2592e6;
26
+ const normEmail = (e) => e.trim().toLowerCase();
27
+ const sha256 = (s) => createHash("sha256").update(s).digest("hex");
28
+ const token = () => randomBytes(32).toString("base64url");
29
+ const storeFile = (dir) => join(dir, "auth.json");
30
+ /** Only a MISSING file is an empty store. A present-but-unreadable/corrupt auth.json
31
+ * must fail CLOSED - treating it as empty would let the owner bootstrap re-run and
32
+ * a later save overwrite every account. */
33
+ function loadStore(dir) {
34
+ let raw;
35
+ try {
36
+ raw = readFileSync(storeFile(dir), "utf8");
37
+ } catch (err) {
38
+ if (err.code === "ENOENT") return {
39
+ users: [],
40
+ invites: [],
41
+ sessions: []
42
+ };
43
+ throw new Error(`auth store unreadable (${err.message}) - refusing to treat it as empty`);
44
+ }
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(raw);
48
+ } catch {
49
+ throw new Error("auth store is corrupt JSON - refusing to treat it as empty. Restore it or delete it deliberately.");
50
+ }
51
+ if (!Array.isArray(parsed?.users) || !Array.isArray(parsed?.invites) || !Array.isArray(parsed?.sessions)) throw new Error("auth store has an unexpected shape - refusing to load it");
52
+ return parsed;
53
+ }
54
+ /** Atomic rewrite (tmp + rename) - a crash mid-write must never lose every account.
55
+ * 0600 throughout: the store holds emails and password verifiers. */
56
+ function saveStore(dir, store) {
57
+ const file = storeFile(dir);
58
+ mkdirSync(dirname(file), { recursive: true });
59
+ const now = Date.now();
60
+ store.invites = store.invites.filter((i) => i.exp > now);
61
+ store.sessions = store.sessions.filter((s) => s.exp > now);
62
+ const tmp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
63
+ const fd = openSync(tmp, "wx", 384);
64
+ try {
65
+ writeSync(fd, JSON.stringify(store, null, 2));
66
+ fsyncSync(fd);
67
+ } finally {
68
+ closeSync(fd);
69
+ }
70
+ renameSync(tmp, file);
71
+ }
72
+ /** Cross-process mutex over auth.json's read-modify-write. Within one Node process
73
+ * the store mutations are already synchronous and atomic; this covers deploy overlap
74
+ * and any accidental multi-instance run (the supported setup is single-instance) -
75
+ * without it, a sign-in that loaded a pre-revoke snapshot could rename it back over a
76
+ * successful revoke. A crashed holder's lock is stolen after 10s; waiting past 5s
77
+ * fails loudly rather than hanging. */
78
+ function withLock(dir, fn) {
79
+ mkdirSync(dir, { recursive: true });
80
+ const lock = join(dir, ".auth.lock");
81
+ const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
82
+ const deadline = Date.now() + 5e3;
83
+ for (;;) try {
84
+ closeSync(openSync(lock, "wx"));
85
+ break;
86
+ } catch (e) {
87
+ if (e.code !== "EEXIST") throw e;
88
+ try {
89
+ if (Date.now() - statSync(lock).mtimeMs > 1e4) {
90
+ unlinkSync(lock);
91
+ continue;
92
+ }
93
+ } catch {
94
+ continue;
95
+ }
96
+ if (Date.now() > deadline) throw new Error("auth store is busy - please retry");
97
+ nap(25);
98
+ }
99
+ try {
100
+ return fn();
101
+ } finally {
102
+ try {
103
+ unlinkSync(lock);
104
+ } catch {}
105
+ }
106
+ }
107
+ const findUser = (store, email) => store.users.find((u) => normEmail(u.email) === normEmail(email));
108
+ /** Mint a single-use invite for an email (this IS the allowlist entry - inviting an
109
+ * address authorizes it). Re-inviting an email replaces its pending invite. The raw
110
+ * token is returned exactly once; only its hash is stored. */
111
+ /** The owner's display name - public-safe (never the email). Null before claim. */
112
+ function ownerName(dir) {
113
+ return loadStore(dir).users.find((u) => u.role === "owner")?.name?.trim() || null;
114
+ }
115
+ /** Peek at a live invite: the claim screens show WHO the invite is for. The raw
116
+ * token is the proof - holding it means the owner sent it to you. */
117
+ function inviteInfo(dir, rawToken) {
118
+ const hash = sha256(rawToken);
119
+ const invite = loadStore(dir).invites.find((i) => i.tokenHash === hash && i.exp > Date.now());
120
+ return invite ? { email: invite.emailNorm } : null;
121
+ }
122
+ function createInvite(dir, email) {
123
+ return withLock(dir, () => {
124
+ const store = loadStore(dir);
125
+ if (findUser(store, email)) throw new Error(`${normEmail(email)} already has an account`);
126
+ const raw = token();
127
+ const exp = Date.now() + INVITE_TTL;
128
+ store.invites = store.invites.filter((i) => i.emailNorm !== normEmail(email));
129
+ store.invites.push({
130
+ emailNorm: normEmail(email),
131
+ tokenHash: sha256(raw),
132
+ exp
133
+ });
134
+ saveStore(dir, store);
135
+ return {
136
+ token: raw,
137
+ exp
138
+ };
139
+ });
140
+ }
141
+ /** Claim an invite: burns it, creates the account, opens the first session. */
142
+ function claimInvite(dir, rawToken, profile) {
143
+ if (profile.password.length < 8) throw new Error("password must be at least 8 characters");
144
+ if (!profile.name.trim()) throw new Error("a display name is required");
145
+ return withLock(dir, () => {
146
+ const store = loadStore(dir);
147
+ const hash = sha256(rawToken);
148
+ const invite = store.invites.find((i) => i.tokenHash === hash && i.exp > Date.now());
149
+ if (!invite) throw new Error("this invite link is invalid, expired, or already used");
150
+ store.invites = store.invites.filter((i) => i !== invite);
151
+ const salt = randomBytes(16).toString("hex");
152
+ const user = {
153
+ email: invite.emailNorm,
154
+ name: profile.name.trim(),
155
+ avatar: profile.avatar,
156
+ role: store.users.length ? "member" : "owner",
157
+ salt,
158
+ hash: scryptSync(profile.password, Buffer.from(salt, "hex"), SCRYPT.keylen, SCRYPT).toString("hex"),
159
+ params: SCRYPT,
160
+ createdAt: Date.now()
161
+ };
162
+ store.users.push(user);
163
+ const session = pushSession(store, user);
164
+ saveStore(dir, store);
165
+ return {
166
+ user,
167
+ session
168
+ };
169
+ });
170
+ }
171
+ /** Password sign-in. One generic failure - never reveal whether the email exists. */
172
+ function signIn(dir, email, password) {
173
+ return withLock(dir, () => {
174
+ const store = loadStore(dir);
175
+ const user = findUser(store, email);
176
+ const salt = user ? Buffer.from(user.salt, "hex") : randomBytes(16);
177
+ const params = user?.params ?? SCRYPT;
178
+ const got = scryptSync(password, salt, params.keylen, params);
179
+ const want = user ? Buffer.from(user.hash, "hex") : randomBytes(SCRYPT.keylen);
180
+ if (!user || got.length !== want.length || !timingSafeEqual(got, want)) return null;
181
+ const session = pushSession(store, user);
182
+ saveStore(dir, store);
183
+ return {
184
+ user,
185
+ session
186
+ };
187
+ });
188
+ }
189
+ function pushSession(store, user) {
190
+ const raw = token();
191
+ store.sessions.push({
192
+ tokenHash: sha256(raw),
193
+ emailNorm: normEmail(user.email),
194
+ exp: Date.now() + SESSION_TTL
195
+ });
196
+ return raw;
197
+ }
198
+ /** Resolve a session token to its user; null when unknown or expired. */
199
+ function sessionUser(dir, rawToken) {
200
+ const store = loadStore(dir);
201
+ const hash = sha256(rawToken);
202
+ const s = store.sessions.find((s) => s.tokenHash === hash && s.exp > Date.now());
203
+ return s ? store.users.find((u) => normEmail(u.email) === s.emailNorm) ?? null : null;
204
+ }
205
+ function signOut(dir, rawToken) {
206
+ withLock(dir, () => {
207
+ const store = loadStore(dir);
208
+ const hash = sha256(rawToken);
209
+ store.sessions = store.sessions.filter((s) => s.tokenHash !== hash);
210
+ saveStore(dir, store);
211
+ });
212
+ }
213
+ /** Update name/avatar on an existing account. */
214
+ function updateProfile(dir, email, patch) {
215
+ return withLock(dir, () => {
216
+ const store = loadStore(dir);
217
+ const user = findUser(store, email);
218
+ if (!user) throw new Error("no such account");
219
+ if (patch.name?.trim()) user.name = patch.name.trim();
220
+ if (patch.avatar !== void 0) user.avatar = patch.avatar || void 0;
221
+ saveStore(dir, store);
222
+ return user;
223
+ });
224
+ }
225
+ /** Remove an account and all its sessions (owner action). The LAST owner cannot be
226
+ * removed - a store with members but no owner has no one left to administer it,
227
+ * and bootstrap will not re-run while any user exists. */
228
+ function revokeUser(dir, email) {
229
+ withLock(dir, () => {
230
+ const store = loadStore(dir);
231
+ if (store.users.find((u) => normEmail(u.email) === normEmail(email))?.role === "owner" && !store.users.some((u) => u.role === "owner" && normEmail(u.email) !== normEmail(email))) throw new Error("cannot remove the last owner - the canvas would have no administrator left");
232
+ store.users = store.users.filter((u) => normEmail(u.email) !== normEmail(email));
233
+ store.sessions = store.sessions.filter((s) => s.emailNorm !== normEmail(email));
234
+ store.invites = store.invites.filter((i) => i.emailNorm !== normEmail(email));
235
+ saveStore(dir, store);
236
+ });
237
+ }
238
+ /** The public shape of a user - what other viewers (and events) may see. */
239
+ const publicUser = (u) => ({
240
+ email: u.email,
241
+ name: u.name,
242
+ avatar: u.avatar
243
+ });
244
+ //#endregion
245
+ export { claimInvite, createInvite, inviteInfo, loadStore, normEmail, ownerName, publicUser, revokeUser, sessionUser, signIn, signOut, updateProfile };
@@ -1,6 +1,6 @@
1
1
  import { i as ROUTE, n as NAME } from "./cli.mjs";
2
2
  import { a as loadConfig, n as scanFrames, o as detectHost } from "./manifest-DW-T52MM.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-Crp4CAma.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-CtQqO5ZZ.mjs";
4
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -43,6 +43,37 @@ function scanAssetRefs(src, moduleId) {
43
43
  }
44
44
  /** Same shape the client's assetUrl accepts: relative, inside design/assets/, no tricks. */
45
45
  const isLocalAssetRef = (p) => !!p && !p.includes(":") && !p.startsWith("/") && !p.startsWith("\\") && !p.split("/").some((s) => s === ".." || s === "");
46
+ /** The publish policy (SPEC-M3 §4): design/publish.json names what ships and with what
47
+ * rights. Publishing is default-CLOSED - no policy and no explicit flag = no build.
48
+ * Returns name -> 'read' | 'comment'. Boards absent from the result do not ship. */
49
+ function resolvePublish(root, allBoards, boardsFlag, allBoardsFlag) {
50
+ const known = (n) => n === "all-scenes" || !!allBoards[n];
51
+ if (boardsFlag !== void 0) {
52
+ const names = boardsFlag.split(",").map((s) => s.trim()).filter(Boolean);
53
+ if (!names.length) throw new Error("--boards was given but named no boards");
54
+ const missing = names.filter((n) => !known(n));
55
+ if (missing.length) throw new Error(`--boards names not found in design/boards/: ${missing.join(", ")}`);
56
+ return Object.fromEntries(names.map((n) => [n, "comment"]));
57
+ }
58
+ if (allBoardsFlag) return Object.fromEntries(["all-scenes", ...Object.keys(allBoards).filter((n) => n !== "all-scenes")].map((n) => [n, "comment"]));
59
+ const policyFile = join(root, "design", "publish.json");
60
+ if (!existsSync(policyFile)) throw new Error("publishing is default-closed and no publish policy exists.\n Either declare one in design/publish.json: { \"boards\": { \"<board>\": \"read\" | \"comment\" } }\n or be explicit: --boards a,b (publish just those) · --all-boards (publish everything)");
61
+ let policy;
62
+ try {
63
+ policy = JSON.parse(readFileSync(policyFile, "utf8"));
64
+ } catch {
65
+ throw new Error("design/publish.json is not valid JSON");
66
+ }
67
+ const entries = Object.entries(policy?.boards ?? {});
68
+ if (!entries.length) throw new Error("design/publish.json has no \"boards\" entries - publishing is default-closed, name what ships");
69
+ const out = {};
70
+ for (const [n, level] of entries) {
71
+ if (!known(n)) throw new Error(`design/publish.json names an unknown board: ${n}`);
72
+ if (level !== "read" && level !== "comment") throw new Error(`design/publish.json: board "${n}" has level "${level}" - use "read" or "comment"`);
73
+ out[n] = level;
74
+ }
75
+ return out;
76
+ }
46
77
  /** Read every board file; returns name -> parsed json. Bad JSON fails the build loudly. */
47
78
  function readBoards(root) {
48
79
  const dir = join(root, "design", "boards");
@@ -104,7 +135,7 @@ export function layoutChain(fileKey) {
104
135
  }
105
136
  `;
106
137
  }
107
- async function buildSite(root, boardsFlag) {
138
+ async function buildSite(root, boardsFlag, allBoardsFlag) {
108
139
  const config = await loadConfig(root);
109
140
  const host = detectHost(root);
110
141
  const pkgDir = packageDir();
@@ -112,13 +143,8 @@ async function buildSite(root, boardsFlag) {
112
143
  const outDir = join(root, "design", ".dist");
113
144
  const manifest = scanFrames(root);
114
145
  const allBoards = readBoards(root);
115
- let publishedNames;
116
- if (boardsFlag !== void 0) {
117
- publishedNames = boardsFlag.split(",").map((s) => s.trim()).filter(Boolean);
118
- if (!publishedNames.length) throw new Error("--boards was given but named no boards");
119
- const missing = publishedNames.filter((n) => n !== "all-scenes" && !allBoards[n]);
120
- if (missing.length) throw new Error(`--boards names not found in design/boards/: ${missing.join(", ")}`);
121
- } else publishedNames = ["all-scenes", ...Object.keys(allBoards).filter((n) => n !== "all-scenes")];
146
+ const rights = resolvePublish(root, allBoards, boardsFlag, allBoardsFlag);
147
+ const publishedNames = Object.keys(rights);
122
148
  const includeAll = publishedNames.includes("all-scenes");
123
149
  const boards = {};
124
150
  for (const n of publishedNames) if (allBoards[n]) boards[n] = allBoards[n];
@@ -138,7 +164,8 @@ async function buildSite(root, boardsFlag) {
138
164
  },
139
165
  boards,
140
166
  names: publishedNames,
141
- default: publishedNames[0]
167
+ default: publishedNames[0],
168
+ rights
142
169
  };
143
170
  const registryFile = posix(join(clientDir, "frame-host", "registry.ts"));
144
171
  const plugins = [{
@@ -258,6 +285,18 @@ async function buildSite(root, boardsFlag) {
258
285
  cpSync(srcFile, dest);
259
286
  copiedAssets++;
260
287
  }
288
+ const commentsDir = join(root, "design", "comments");
289
+ if (existsSync(commentsDir)) {
290
+ let seeded = 0;
291
+ for (const n of publishedNames) {
292
+ const f = join(commentsDir, `${n}.jsonl`);
293
+ if (!existsSync(f)) continue;
294
+ mkdirSync(join(outDir, "design", "comments"), { recursive: true });
295
+ cpSync(f, join(outDir, "design", "comments", `${n}.jsonl`));
296
+ seeded++;
297
+ }
298
+ if (seeded) console.log(` comments: ${seeded} board log${seeded === 1 ? "" : "s"} seeded`);
299
+ }
261
300
  let name = basename(root);
262
301
  try {
263
302
  name = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name ?? name;
@@ -282,10 +321,11 @@ async function buildSite(root, boardsFlag) {
282
321
  writeFileSync(join(outDir, "meta.json"), JSON.stringify({
283
322
  name,
284
323
  branding: config.share.branding,
285
- logo
324
+ logo,
325
+ rights
286
326
  }));
287
327
  console.log(`\n ${NAME} build → design/.dist`);
288
- console.log(` boards: ${publishedNames.join(", ")}`);
328
+ console.log(` boards: ${publishedNames.map((n) => `${n} (${rights[n]})`).join(", ")}`);
289
329
  console.log(` frames: ${frames.length}${includeAll ? "" : ` of ${manifest.frames.length} (build-time filter)`}`);
290
330
  if (copiedAssets) console.log(` assets: ${copiedAssets} referenced file${copiedAssets === 1 ? "" : "s"} from design/assets/ (unreferenced assets never ship)`);
291
331
  if (!includeAll && existsSync(join(root, "public"))) console.log(` note: the host public/ directory ships in full - the --boards filter covers frames, not public assets`);
package/dist/cli.mjs CHANGED
@@ -39,14 +39,14 @@ function version() {
39
39
  }
40
40
  const cli = cac(NAME);
41
41
  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) => {
42
- const { init } = await import("./init-DolP_Ld4.mjs");
42
+ const { init } = await import("./init-Ck8z-HiD.mjs");
43
43
  init(resolve(opts.root), {
44
44
  mode: opts.mode === "embedded" ? "embedded" : "studio",
45
45
  demo: opts.demo !== false
46
46
  });
47
47
  });
48
48
  cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
49
- const { dev } = await import("./dev-C2oTKuXe.mjs");
49
+ const { dev } = await import("./dev-DZi1yRhn.mjs");
50
50
  let port;
51
51
  if (opts.port !== void 0) {
52
52
  const n = Number(opts.port);
@@ -55,18 +55,18 @@ cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root",
55
55
  }
56
56
  await dev(resolve(opts.root), port);
57
57
  });
58
- cli.command("build", "Static export → design/.dist").option("--boards <names>", "Publish only these boards (comma-separated); the frame filter is applied at build time").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
59
- const { buildSite } = await import("./build-Ckmyci3O.mjs");
58
+ 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("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
59
+ const { buildSite } = await import("./build-p3xmXU3b.mjs");
60
60
  try {
61
61
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
62
- await buildSite(resolve(opts.root), boards);
62
+ await buildSite(resolve(opts.root), boards, opts.allBoards === true);
63
63
  } catch (err) {
64
64
  console.error(`[${NAME}] build failed: ${err.message}`);
65
65
  process.exit(1);
66
66
  }
67
67
  });
68
68
  cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default $PORT or 4199)").action(async (opts) => {
69
- const { serve } = await import("./serve-OtA9Nlow.mjs");
69
+ const { serve } = await import("./serve-D_KBK7Oy.mjs");
70
70
  let port;
71
71
  if (opts.port !== void 0) {
72
72
  const n = Number(opts.port);
@@ -74,6 +74,15 @@ cli.command("serve", "Serve design/.dist (set MARVER_PASSWORD to gate it)").opti
74
74
  }
75
75
  serve(resolve(opts.root), port);
76
76
  });
77
+ 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("--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) => {
78
+ const { commentsCommand } = await import("./comments-odHzYdO3.mjs");
79
+ try {
80
+ await commentsCommand(resolve(opts.root), action, value, opts);
81
+ } catch (err) {
82
+ console.error(`[${NAME}] ${err.message}`);
83
+ process.exit(1);
84
+ }
85
+ });
77
86
  cli.help();
78
87
  cli.version(version());
79
88
  cli.parse();