@marver-design/marver 0.3.1 → 0.5.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 (50) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +2 -0
  3. package/dist/auth-B36fMCM3.mjs +245 -0
  4. package/dist/{build-Ckmyci3O.mjs → build-BrCl9hJS.mjs} +56 -14
  5. package/dist/cli.mjs +15 -6
  6. package/dist/collab-CXy8gqoz.mjs +297 -0
  7. package/dist/comments-Ba8mU600.mjs +90 -0
  8. package/dist/comments-odHzYdO3.mjs +179 -0
  9. package/dist/dev-DdeU-Jst.mjs +162 -0
  10. package/dist/{init-DolP_Ld4.mjs → init-DsCUmlCW.mjs} +2 -1
  11. package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
  12. package/dist/{plugin-Crp4CAma.mjs → plugin-BtSGAm2h.mjs} +169 -19
  13. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  14. package/dist/serve-D_KBK7Oy.mjs +431 -0
  15. package/dist/sync-CkBk-tUk.mjs +248 -0
  16. package/package.json +3 -1
  17. package/src/client/content/diagram.tsx +46 -2
  18. package/src/client/content/index.tsx +6 -2
  19. package/src/client/content/md.ts +29 -0
  20. package/src/client/content/palette.ts +6 -0
  21. package/src/client/frame-host/bridge.js +269 -3
  22. package/src/client/frame-host/serialize.ts +195 -0
  23. package/src/client/shell/App.tsx +131 -17
  24. package/src/client/shell/Comments.tsx +464 -0
  25. package/src/client/shell/Play.tsx +24 -0
  26. package/src/client/shell/canvas/Canvas.tsx +66 -29
  27. package/src/client/shell/canvas/FrameNode.tsx +130 -10
  28. package/src/client/shell/canvas/frame-registry.ts +18 -0
  29. package/src/client/shell/canvas/snapshots.ts +233 -0
  30. package/src/client/shell/comments-store.ts +180 -0
  31. package/src/client/shell/cursor-arrow-dark.svg +1 -0
  32. package/src/client/shell/cursor-arrow.svg +1 -0
  33. package/src/client/shell/hash.ts +14 -3
  34. package/src/client/shell/icons.tsx +6 -0
  35. package/src/client/shell/labels.ts +10 -0
  36. package/src/client/shell/perf.ts +92 -0
  37. package/src/client/shell/store.ts +101 -6
  38. package/src/client/shell/styles.css +196 -7
  39. package/src/client/stage/main.tsx +2 -0
  40. package/src/shared/events.ts +103 -0
  41. package/templates/AGENTS-embedded.md +13 -1
  42. package/templates/AGENTS-studio.md +13 -1
  43. package/templates/instructions/boards.md +6 -3
  44. package/templates/instructions/configure.md +28 -0
  45. package/templates/instructions/iterate.md +38 -0
  46. package/templates/instructions/publish.md +129 -0
  47. package/templates/instructions/reference/color.md +22 -1
  48. package/templates/instructions/shape.md +23 -16
  49. package/dist/dev-C2oTKuXe.mjs +0 -97
  50. package/dist/serve-OtA9Nlow.mjs +0 -207
package/CHANGELOG.md CHANGED
@@ -2,6 +2,157 @@
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.5.0 - 2026-08-15
6
+
7
+ The performance & fidelity release (SPEC-M5): the canvas stops jiggling. Moving around a board no
8
+ longer swaps between two documents on every pan/zoom - each passive frame renders as a lean DOM
9
+ snapshot that IS what you see, and the real live app takes over the moment you interact with it.
10
+
11
+ ### Added
12
+
13
+ - **Lean-primary rendering.** Every passive frame shows a **DOM snapshot** - a self-contained static
14
+ copy of the frame (real DOM + real CSS, zero JavaScript) served in a `sandbox="allow-same-origin"`
15
+ iframe. It reflows on resize with the browser's own layout engine and carries the app's exact colors
16
+ (no rasterisation), so panning, zooming, and device-sweeping a board is smooth and pixel-honest. The
17
+ full live app sits underneath and swaps in instantly when you focus a frame (double-click), or in
18
+ laser/comment mode. This replaces the earlier screenshot facade, which invented colors and jittered.
19
+ - **Publish parity.** The lean tier now works in published builds (`marver build` → `marver serve`),
20
+ not just dev - captured client-side from the bundled same-origin frames, no build-time renderer.
21
+ - **Faster first paint.** Leans capture bounded-parallel and viewport-first, so the frames you're
22
+ looking at appear first and a big board settles in seconds instead of tens of seconds.
23
+ - **Content-frame color families.** Tag a diagram node with a built-in family - `HQ:::blue`,
24
+ `Carriers:::orange`, `Drivers:::purple` (also `green red gray`) - and it gets a filled, on-brand
25
+ color with a legible border in both themes, no `classDef` boilerplate. The same six names work in
26
+ `Md` prose as `:blue[the shipper's world]`, so a sentence and the diagram beside it read as one
27
+ color language.
28
+ - **`Head :: gloss` diagram labels.** A node label written `Head :: gloss` renders the head bold on
29
+ top with the gloss lighter and smaller below - a box scans as label-then-detail, no run-on.
30
+ - **Authoring doctrine that ships with the tool.** The scaffolded instructions
31
+ (`instructions/shape.md`, `instructions/reference/color.md`) now teach an agent these conventions -
32
+ the `::` label hierarchy, the `:::family` / `:blue[…]` palette, and "pick one family per concept and
33
+ hold it" - so diagrams and highlighted prose come out consistent by default instead of hand-rolled
34
+ hex and one-off `classDef`s.
35
+
36
+ ### Changed
37
+
38
+ - **Sidebar header** shows the humanized repo name (`marver-pilot` → "Marver Pilot", ellipsed if
39
+ long); the logo links to marver.design.
40
+ - **Sidebar board/scene labels** are humanized - kebab filenames render Title Case (`tms-specs` →
41
+ "Tms Specs"), dropping the dashes, while an explicit `meta.title` is honored verbatim.
42
+ - **App cursor** is the marver arrowhead - tilted, rounded, small, soft-shadowed, and theme-adaptive
43
+ (black-on-light / white-on-dark); reverts to a normal pointer in interact/prototype and keeps the
44
+ pin/crosshair in comment/laser mode.
45
+ - **Copy-file-path shortcut** moved to `Shift+P` (was a mislabeled `C`).
46
+
47
+ ### Fixed
48
+
49
+ - The canvas "jiggle" - text shifting ~1-2px when you click or zoom a frame - is gone; there is no
50
+ longer a per-gesture document swap to shift it.
51
+ - Mermaid diagrams no longer pop in/out or flash the wrong theme during zoom (async render is awaited;
52
+ a diagram's baked colors re-capture on theme change; the cover's color-scheme is pinned to the
53
+ frame theme, not the viewer's OS).
54
+ - A frame you've scrolled, typed into, or themed re-captures faithfully; agent edits (HMR) drop the
55
+ stale snapshot and rebuild; slow data that lands shortly after load triggers one bounded re-capture
56
+ (data that changes much later shows live the moment you focus the frame, and the lean rebuilds when
57
+ you leave it).
58
+
59
+ ### Known limitations
60
+
61
+ - Memory targets typical authoring boards (~15-20 frames); dozens of heavy production apps need the
62
+ bounded-residency milestone. A frame the serializer can't render faithfully (canvas/video/open- or
63
+ script-created-closed shadow-DOM/nested-iframe/cross-origin-CSS/blocked-CSP/oversized) degrades to
64
+ live automatically. (One narrow edge: a declarative closed shadow root can't be detected and may
65
+ render stale - rare in practice.)
66
+
67
+ ## 0.4.0 - 2026-08-14
68
+
69
+ The collaboration release (SPEC-M3): the canvas becomes a place where colleagues,
70
+ the designer, and the coding agent close the feedback loop together.
71
+
72
+ ### Added
73
+
74
+ - **Element-anchored comments.** `C` enters comment mode: click any element inside a
75
+ frame (laser outlines guide you) and the thread pins to it - fractional position
76
+ inside the element's box, so pins ride through responsive reflow. Threads are
77
+ Google-Docs-shaped: root + one level of replies, resolve/reopen. Anchors survive
78
+ agent edits via a verified ladder (semantics → CSS path → fuzzy quote match);
79
+ a dead anchor parks the thread visibly at the frame edge, never silently deleted.
80
+ Inactive frames collapse their open threads into a top-right avatar stack.
81
+ `Shift+C` hides all pins; `?c=<thread>` deep-links a specific thread through the
82
+ password gate (copy-link on every card).
83
+ - **Real commenter identity, no email infrastructure.** Viewers on a published canvas
84
+ claim an account through a single-use invite link (owner mints it; the link travels
85
+ over Slack/DM) - display name, password, avatar. Accounts are scrypt-verified with
86
+ per-user salts; sessions survive restarts. The first account owns the canvas;
87
+ `MARVER_OWNER_EMAIL` prints the owner's one-time claim link in the deploy logs.
88
+ Avatars fall back to initials on a deterministic color.
89
+ - **Gate v2: one credential per persona.** The gate on a collaboration canvas has
90
+ three doors: guests pay the canvas password (read-only), members sign in with
91
+ their OWN password (their session IS gate passage - they never touch the shared
92
+ secret), and an invite link (`<url>/#/i/<token>`) opens straight into the claim -
93
+ email shown as an INVITED chip, profile-picture picker (client-side 128px
94
+ downscale), display name, password. Sign-in/claim endpoints sit in front of the
95
+ gate (rate-limited, non-enumerating); rotating the canvas password only ever
96
+ affects guests. Primary buttons stay disabled until their mandatory fields are
97
+ filled, with a tooltip naming what's missing. The boards payload carries the
98
+ owner's display name, so a read-only refusal says who to ask. Static canvases
99
+ keep the single-field gate untouched.
100
+ - **One deploy, comments live everywhere.** `marver serve` grows a collaboration API
101
+ (REST + SSE) when `MARVER_DATA_DIR` names a durable volume; the published canvas is
102
+ the comments' home. `marver dev` two-way syncs every 30s (`marver comments connect
103
+ <url>` once), so client feedback lands in `design/comments/<board>.jsonl` - a
104
+ git-tracked, append-only event log the agent reads with zero tooling. Republishing
105
+ never clobbers collected feedback (the store unions the bundle seed on boot).
106
+ - **The agent works the queue.** `marver comments list --open --json` / `reply` /
107
+ `resolve --addressed-in <frame>` - resolving records which variant answered the
108
+ feedback, making the fork-don't-overwrite doctrine auditable. `marver comments
109
+ invite <email>` mints single-use invite links from the CLI (owner only), `revoke
110
+ <email>` retires an account. `instructions/iterate.md` carries the
111
+ comments-as-work-queue discipline; `instructions/publish.md` is the agent-facing
112
+ deploy runbook (boards policy, gate, volume, accounts) and AGENTS.md routes to it.
113
+ - **Laser mode.** `L` (or the toolbar crosshair) outlines every element in every frame
114
+ with depth-hued borders (60° per nesting level) plus a DevTools-style hover label.
115
+ Zero layout shift - outlines only. Clicking an element copies its full address for
116
+ the agent - frame source file + CSS path (+ JSX source location when stamped).
117
+ Comment mode shows only the hover highlight (no full rainbow) and a chat-teardrop
118
+ cursor, so picking an element to comment on stays calm.
119
+ - **Default-closed publishing.** `marver build` now requires a publish policy:
120
+ `design/publish.json` names each published board with `read` or `comment` rights
121
+ (enforced server-side, not just hidden in UI). `--boards a,b` stays as an explicit
122
+ override; publishing everything takes a deliberate `--all-boards`.
123
+
124
+ ### Changed
125
+
126
+ - **`c` is comment mode now** (the Figma/Miro convention). Copy-selected-file-paths
127
+ moved from `c` to `y`.
128
+ - `marver build` without a publish policy fails with instructions instead of
129
+ publishing everything - the privacy default flipped closed.
130
+
131
+ ### Security
132
+
133
+ - Comment mutations are CSRF-protected (double-submit cookie + origin checks) and
134
+ rate-limited; session and invite tokens are stored hashed; every pushed event is
135
+ validated hard (author must match the session, thread ids cannot be hijacked,
136
+ edits are owner-only, timestamps cannot come from the future) - and accepted
137
+ events are never rewritten, so id-keyed sync converges byte-identically.
138
+ - v1 trust boundary, stated plainly: frame code in your design repo runs same-origin
139
+ with the shell - review what you merge. Full frame sandboxing is the v1.1 follow-up
140
+ (SPEC-M3 §0 records the probe results and the plan).
141
+
142
+ ### Durability & concurrency
143
+
144
+ - Comment appends and auth writes are `fsync`'d before they are acknowledged - a
145
+ comment or account confirmed to the client survives a crash or volume interruption,
146
+ not just the page cache.
147
+ - `auth.json`'s read-modify-write is guarded by a cross-process lock (stale-stolen
148
+ after 10s, never deadlocks) so a deploy overlap or an accidental second instance
149
+ cannot resurrect a revoked account or drop an invite. The comment log needs no lock:
150
+ append-only + id-keyed union is conflict-free by construction (and git merges it
151
+ with `merge=union`). Two honest v1 limits, documented not hidden: thread ordering
152
+ uses client timestamps (a badly-skewed clock can mis-order a resolve/reopen race -
153
+ re-resolve to fix), and comment logs have no hard size ceiling (fine at the design-
154
+ review scale marver targets; a per-board cap is a later concern).
155
+
5
156
  ## 0.3.1 - 2026-08-13
6
157
 
7
158
  ### Fixed
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
- 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";
2
+ import { o as loadConfig, r as scanFrames, s as detectHost } from "./manifest-C8FODq2S.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BtSGAm2h.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 = [{
@@ -223,7 +250,9 @@ async function buildSite(root, boardsFlag) {
223
250
  for (const f of frames.filter((x) => x.kind === "html")) {
224
251
  const src = readFileSync(join(root, f.file), "utf8");
225
252
  const inject = `${frameCss}\n<script type="module" src="/assets/bridge.js?html=1"><\/script>\n`;
226
- const html = src.includes("</head>") ? src.replace("</head>", `${inject}</head>`) : inject + src;
253
+ let html = src.includes("</head>") ? src.replace("</head>", `${inject}</head>`) : inject + src;
254
+ const shim = `<script>(function(){var a=Element.prototype.attachShadow;if(a)Element.prototype.attachShadow=function(i){if(i&&i.mode==='closed')window.__mvClosedShadow=1;return a.call(this,i)};})();<\/script>`;
255
+ html = /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (m) => m + shim) : shim + html;
227
256
  mkdirSync(dirname(join(outDir, f.file)), { recursive: true });
228
257
  writeFileSync(join(outDir, f.file), html);
229
258
  }
@@ -258,6 +287,18 @@ async function buildSite(root, boardsFlag) {
258
287
  cpSync(srcFile, dest);
259
288
  copiedAssets++;
260
289
  }
290
+ const commentsDir = join(root, "design", "comments");
291
+ if (existsSync(commentsDir)) {
292
+ let seeded = 0;
293
+ for (const n of publishedNames) {
294
+ const f = join(commentsDir, `${n}.jsonl`);
295
+ if (!existsSync(f)) continue;
296
+ mkdirSync(join(outDir, "design", "comments"), { recursive: true });
297
+ cpSync(f, join(outDir, "design", "comments", `${n}.jsonl`));
298
+ seeded++;
299
+ }
300
+ if (seeded) console.log(` comments: ${seeded} board log${seeded === 1 ? "" : "s"} seeded`);
301
+ }
261
302
  let name = basename(root);
262
303
  try {
263
304
  name = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name ?? name;
@@ -282,10 +323,11 @@ async function buildSite(root, boardsFlag) {
282
323
  writeFileSync(join(outDir, "meta.json"), JSON.stringify({
283
324
  name,
284
325
  branding: config.share.branding,
285
- logo
326
+ logo,
327
+ rights
286
328
  }));
287
329
  console.log(`\n ${NAME} build → design/.dist`);
288
- console.log(` boards: ${publishedNames.join(", ")}`);
330
+ console.log(` boards: ${publishedNames.map((n) => `${n} (${rights[n]})`).join(", ")}`);
289
331
  console.log(` frames: ${frames.length}${includeAll ? "" : ` of ${manifest.frames.length} (build-time filter)`}`);
290
332
  if (copiedAssets) console.log(` assets: ${copiedAssets} referenced file${copiedAssets === 1 ? "" : "s"} from design/assets/ (unreferenced assets never ship)`);
291
333
  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-DsCUmlCW.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-DdeU-Jst.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-BrCl9hJS.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();