@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
@@ -0,0 +1,431 @@
1
+ import { n as NAME } from "./cli.mjs";
2
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
+ import { extname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
5
+ import { createServer } from "node:http";
6
+ //#region src/server/serve.ts
7
+ /**
8
+ * `marver serve` - a zero-dependency static server for design/.dist with an optional
9
+ * password gate (SPEC-M2 §4b). No MARVER_PASSWORD → plain static serving. With it, the
10
+ * bundle is never sent pre-auth: every unauthenticated request gets the gate page, auth
11
+ * is a POST compare + an HMAC-signed 30-day cookie keyed off the password itself (all
12
+ * instances agree, nothing stored server-side).
13
+ */
14
+ const MIME = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".js": "text/javascript",
17
+ ".mjs": "text/javascript",
18
+ ".css": "text/css",
19
+ ".json": "application/json",
20
+ ".svg": "image/svg+xml",
21
+ ".png": "image/png",
22
+ ".ico": "image/x-icon",
23
+ ".webmanifest": "application/manifest+json",
24
+ ".woff": "font/woff",
25
+ ".woff2": "font/woff2",
26
+ ".map": "application/json",
27
+ ".txt": "text/plain"
28
+ };
29
+ const COOKIE = "mv_a";
30
+ const MONTH = 2592e3;
31
+ async function serve(root, portFlag) {
32
+ const dist = join(root, "design", ".dist");
33
+ if (!existsSync(join(dist, "index.html"))) {
34
+ console.error(`[${NAME}] design/.dist not found - run \`npx ${NAME} build\` first.`);
35
+ process.exit(1);
36
+ }
37
+ const realDist = realpathSync(dist);
38
+ let meta = {
39
+ name: "Marver",
40
+ branding: true
41
+ };
42
+ try {
43
+ meta = {
44
+ ...meta,
45
+ ...JSON.parse(readFileSync(join(dist, "meta.json"), "utf8"))
46
+ };
47
+ } catch {}
48
+ let collab = null;
49
+ let bearerCheck = null;
50
+ let sessionCheck = null;
51
+ if (process.env.MARVER_DATA_DIR) {
52
+ const { collabHandler } = await import("./collab-CXy8gqoz.mjs");
53
+ const { appendEvents, readLog } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
54
+ const { dataDir } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
55
+ const dir = dataDir();
56
+ const seedDir = join(dist, "design", "comments");
57
+ if (existsSync(seedDir)) for (const f of readdirSync(seedDir)) {
58
+ if (!f.endsWith(".jsonl")) continue;
59
+ const board = f.slice(0, -6);
60
+ appendEvents(join(dir, "comments"), board, readLog(seedDir, board));
61
+ }
62
+ collab = collabHandler(dir, dist);
63
+ const { loadStore, createInvite, normEmail, sessionUser } = await import("./auth-B36fMCM3.mjs");
64
+ bearerCheck = (token) => sessionUser(dir, token);
65
+ sessionCheck = (req) => {
66
+ const tok = /(?:^|;\s*)mv_s=([\w-]+)/.exec(String(req.headers.cookie ?? ""))?.[1];
67
+ return tok ? sessionUser(dir, tok) : null;
68
+ };
69
+ const owner = process.env.MARVER_OWNER_EMAIL;
70
+ if (owner) {
71
+ const store = loadStore(dir);
72
+ if (!store.users.length && !store.invites.some((i) => i.emailNorm === normEmail(owner))) {
73
+ const { token } = createInvite(dir, owner);
74
+ console.log(`\n owner bootstrap for ${normEmail(owner)} (single-use, 7 days):`);
75
+ console.log(` in the browser: <canvas-url>/#/i/${token}`);
76
+ console.log(` from the repo: npx ${NAME} comments connect <this-url> --invite ${token}\n`);
77
+ }
78
+ }
79
+ }
80
+ const password = process.env.MARVER_PASSWORD ?? "";
81
+ const verifier = password ? scryptSync(password, "marver-gate", 32) : null;
82
+ const cookieKey = randomBytes(32);
83
+ const sign = (exp) => createHmac("sha256", cookieKey).update(`v1.${exp}`).digest("hex");
84
+ const authed = (req) => {
85
+ const m = /(?:^|;\s*)mv_a=(\d+)\.([0-9a-f]+)/.exec(String(req.headers.cookie ?? ""));
86
+ if (!m) return false;
87
+ const exp = Number(m[1]);
88
+ if (!Number.isFinite(exp) || exp * 1e3 < Date.now()) return false;
89
+ const want = Buffer.from(sign(exp)), got = Buffer.from(m[2]);
90
+ return want.length === got.length && timingSafeEqual(want, got);
91
+ };
92
+ const server = createServer((req, res) => {
93
+ const url = new URL(req.url ?? "/", "http://x");
94
+ if (verifier) {
95
+ if (req.method === "POST" && url.pathname === "/__mv/auth") {
96
+ let body = "";
97
+ req.on("data", (c) => {
98
+ body += c;
99
+ if (body.length > 1e4) req.destroy();
100
+ });
101
+ req.on("end", () => {
102
+ const form = new URLSearchParams(body);
103
+ const given = form.get("password") ?? "";
104
+ if (timingSafeEqual(scryptSync(given, "marver-gate", 32), verifier)) {
105
+ const exp = Math.floor(Date.now() / 1e3) + MONTH;
106
+ const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
107
+ res.setHeader("set-cookie", `${COOKIE}=${exp}.${sign(exp)}; Path=/; Max-Age=${MONTH}; HttpOnly; SameSite=Lax${secure}`);
108
+ const next = form.get("next") ?? "";
109
+ res.statusCode = 303;
110
+ res.setHeader("location", /^#\/[\w\/?&=%.,~-]*$/.test(next) ? `/${next}` : "/");
111
+ return res.end();
112
+ }
113
+ return gate(res, meta, !!collab, "Wrong canvas password - try again");
114
+ });
115
+ return;
116
+ }
117
+ let decoded = url.pathname;
118
+ try {
119
+ decoded = decodeURIComponent(url.pathname);
120
+ } catch {}
121
+ const cosmetic = /^\/__mv\/favicon\/[\w-]+(?:\.[\w-]+)+$/.test(decoded) || /^\/__mv\/logo\.(?:svg|png)$/.test(decoded);
122
+ if (!authed(req) && !cosmetic && !sessionCheck?.(req)) {
123
+ const bearerOk = collab && url.pathname.startsWith("/__mv/api/") && (() => {
124
+ const tok = /^Bearer ([\w-]+)$/.exec(String(req.headers.authorization ?? ""))?.[1];
125
+ return !!tok && !!bearerCheck?.(tok);
126
+ })();
127
+ const preGate = collab && (req.method === "POST" && (url.pathname === "/__mv/api/auth/signin" || url.pathname === "/__mv/api/auth/claim") || req.method === "GET" && url.pathname === "/__mv/api/invite-info");
128
+ if (!bearerOk && !preGate) return gate(res, meta, !!collab);
129
+ }
130
+ }
131
+ if (collab && url.pathname.startsWith("/__mv/api/")) {
132
+ collab(req, res, url).then((handled) => {
133
+ if (!handled) {
134
+ res.statusCode = 404;
135
+ res.end("not found");
136
+ }
137
+ }).catch(() => {
138
+ res.statusCode = 500;
139
+ res.end("error");
140
+ });
141
+ return;
142
+ }
143
+ let path;
144
+ try {
145
+ path = decodeURIComponent(url.pathname);
146
+ } catch {
147
+ res.statusCode = 400;
148
+ return res.end("bad request");
149
+ }
150
+ if (path.endsWith("/")) path += "index.html";
151
+ let file = resolve(dist, path.slice(1));
152
+ try {
153
+ const real = realpathSync(file);
154
+ const rel = relative(realDist, real);
155
+ if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("outside dist");
156
+ file = real;
157
+ } catch {
158
+ file = join(dist, "index.html");
159
+ }
160
+ if (!extname(file)) file = join(dist, "index.html");
161
+ try {
162
+ const content = readFileSync(file);
163
+ res.setHeader("content-type", MIME[extname(file)] ?? "application/octet-stream");
164
+ const cache = verifier ? "private, no-store" : file.startsWith(join(dist, "assets")) ? "public, max-age=31536000, immutable" : "no-store";
165
+ res.setHeader("cache-control", cache);
166
+ res.end(content);
167
+ } catch {
168
+ res.statusCode = 404;
169
+ res.end("not found");
170
+ }
171
+ });
172
+ const port = portFlag ?? (Number(process.env.PORT) || 4199);
173
+ server.on("error", (err) => {
174
+ if (err.code === "EADDRINUSE") {
175
+ console.error(`[${NAME}] port ${port} is already in use - another \`${NAME} serve\` still running? Stop it, or pass --port <n>.`);
176
+ process.exit(1);
177
+ }
178
+ throw err;
179
+ });
180
+ server.listen(port, () => {
181
+ console.log(`\n ${NAME} serving design/.dist → http://localhost:${port}/`);
182
+ console.log(verifier ? " gate: ON (MARVER_PASSWORD set)\n" : " gate: off - set MARVER_PASSWORD to require a password\n");
183
+ });
184
+ return server;
185
+ }
186
+ /** The Marver logo mark (ParallelogramDuo, same as the shell's sidebar). */
187
+ const MARK_AT = (size) => `<svg viewBox="0 0 256 256" width="${size}" height="${size}" fill="currentColor" aria-hidden><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1"/><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z"/></svg>`;
188
+ const MARK = MARK_AT(17);
189
+ const MARK_LG = MARK_AT(24);
190
+ /** The gate: the canvas's own antechamber - light ground, dot grid, glass card in the
191
+ * shell's exact token language. Three doors, one credential each (SPEC-M3 gate v2):
192
+ * guests pay the canvas password, members sign in with their OWN password, and an
193
+ * invite link (#/i/<token>) opens straight into the claim. The member and claim
194
+ * states exist only on collaboration canvases - a static canvas keeps one field. */
195
+ function gate(res, meta, collabOn, error) {
196
+ const name = meta.name ? meta.name[0].toUpperCase() + meta.name.slice(1) : "Marver";
197
+ const appMark = meta.logo ? `<img src="${esc(meta.logo)}" alt="" width="24" height="24" />` : MARK_LG;
198
+ const title = meta.branding ? `${name} | Marver - Visualize your software` : name;
199
+ const desc = meta.branding ? `${name}, shared as a live Marver canvas - real screens and prototypes, built from the codebase. Marver is the agent-native design canvas.` : `${name} - a private design canvas.`;
200
+ res.statusCode = 200;
201
+ res.setHeader("content-type", "text/html; charset=utf-8");
202
+ res.setHeader("cache-control", "no-store");
203
+ res.end(`<!doctype html>
204
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
205
+ <title>${esc(title)}</title>
206
+ <meta name="description" content="${esc(desc)}" />
207
+ <meta name="robots" content="noindex" />
208
+ <meta name="theme-color" content="#e7e9ef" />
209
+ <meta property="og:title" content="${esc(title)}" />
210
+ <meta property="og:description" content="${esc(desc)}" />
211
+ ${meta.branding ? "<meta property=\"og:site_name\" content=\"Marver\" />" : ""}
212
+ <meta property="og:type" content="website" />
213
+ <meta name="twitter:card" content="summary" />
214
+ <link rel="icon" href="/__mv/favicon/favicon.ico" sizes="48x48" />
215
+ <link rel="icon" type="image/png" sizes="32x32" href="/__mv/favicon/favicon-32x32.png" />
216
+ <link rel="icon" type="image/png" sizes="16x16" href="/__mv/favicon/favicon-16x16.png" />
217
+ <link rel="apple-touch-icon" href="/__mv/favicon/apple-touch-icon.png" />
218
+ <style>
219
+ * { box-sizing: border-box; margin: 0 }
220
+ body { min-height: 100vh; display: flex; align-items: center; justify-content: center;
221
+ background-color: #e7e9ef;
222
+ background-image: radial-gradient(#c9cbd5 1px, transparent 1px); background-size: 20px 20px;
223
+ font: 500 14px -apple-system, system-ui, sans-serif; color: #18181b;
224
+ -webkit-font-smoothing: antialiased }
225
+ main { display: flex; flex-direction: column; align-items: center; gap: 22px; padding: 16px }
226
+ .card { width: 340px; padding: 24px; border-radius: 24px;
227
+ background: rgba(255, 255, 255, .64); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
228
+ border: 1px solid rgba(24, 24, 27, .1);
229
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .65), 0 1px 2px rgba(24, 24, 27, .05), 0 4px 12px -6px rgba(24, 24, 27, .12) }
230
+ section { display: none; flex-direction: column; gap: 14px }
231
+ section.on { display: flex; animation: rise .24s cubic-bezier(.32, .72, .35, 1) }
232
+ @keyframes rise { from { opacity: 0; transform: translateY(5px) } }
233
+ header { display: flex; align-items: center; gap: 10px; padding: 4px 2px 4px 4px }
234
+ header svg, header img { color: #0088ff; flex: none; border-radius: 6px }
235
+ h1 { font-size: 17px; font-weight: 600; letter-spacing: -.01em }
236
+ p { font-size: 12.5px; line-height: 1.5; color: rgba(24, 24, 27, .66); padding: 0 4px }
237
+ input { height: 40px; padding: 0 16px; border-radius: 999px; border: 1px solid rgba(24, 24, 27, .14);
238
+ background: #fff; color: #18181b; font: inherit; outline: none; width: 100%;
239
+ transition: border-color .15s, box-shadow .15s }
240
+ input::placeholder { color: rgba(24, 24, 27, .35) }
241
+ input:focus { border-color: #0088ff; box-shadow: 0 0 0 3px rgba(0, 136, 255, .18) }
242
+ .stack { display: flex; flex-direction: column; gap: 10px }
243
+ /* spacing rhythm on the claim: description breathes before the fields, a hairline
244
+ divides credentials from identity, and the CTA sits clear of the name row */
245
+ .lead { margin-bottom: 6px }
246
+ .cdiv { border: 0; height: 1px; background: rgba(24, 24, 27, .09); margin: 2px 2px }
247
+ .ctawrap { margin-top: 8px }
248
+ .chip { height: 40px; box-sizing: border-box; padding: 0 16px; border-radius: 999px;
249
+ border: 1px solid transparent; background: rgba(24, 24, 27, .05);
250
+ display: flex; align-items: center; justify-content: space-between; gap: 10px; overflow: hidden }
251
+ .chip b { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
252
+ .chip span { font-size: 10.5px; font-weight: 600; letter-spacing: .04em; color: rgba(24, 24, 27, .4); flex: none }
253
+ .idrow { display: flex; align-items: center; gap: 10px }
254
+ .pfp { position: relative; width: 44px; height: 44px; border-radius: 50%; border: 1.5px dashed rgba(24, 24, 27, .3);
255
+ background: #fff center/cover no-repeat; color: rgba(24, 24, 27, .4); font-size: 18px; flex: none;
256
+ display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0;
257
+ transition: border-color .15s, color .15s }
258
+ .pfp:hover { border-color: #0088ff; color: #0088ff }
259
+ .pfp.set { border-style: solid; border-color: rgba(24, 24, 27, .12); color: transparent }
260
+ .pfp::after { content: attr(data-tip); position: absolute; bottom: calc(100% + 8px); left: 0;
261
+ padding: 5px 10px; border-radius: 7px; background: #18181b; color: #fafafa;
262
+ font: 600 11px -apple-system, system-ui, sans-serif; white-space: nowrap; pointer-events: none;
263
+ opacity: 0; transform: translateY(3px); transition: opacity .15s, transform .15s }
264
+ .pfp:hover::after { opacity: 1; transform: translateY(0) }
265
+ button.cta { height: 40px; border: 0; border-radius: 999px; background: #18181b; color: #fafafa;
266
+ font: 600 13px -apple-system, system-ui, sans-serif; cursor: pointer; width: 100%;
267
+ transition: background .18s, color .18s }
268
+ button.cta:hover:not(:disabled) { background: #000 }
269
+ button.cta:disabled { background: rgba(24, 24, 27, .35); cursor: default }
270
+ .ctawrap { position: relative }
271
+ .tip { display: none; position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%);
272
+ padding: 5px 10px; border-radius: 7px; background: #18181b; color: #fafafa;
273
+ font: 600 11px -apple-system, system-ui, sans-serif; white-space: nowrap; pointer-events: none;
274
+ animation: rise .18s cubic-bezier(.32, .72, .35, 1) }
275
+ .ctawrap:hover button:disabled ~ .tip { display: block }
276
+ .swap { text-align: center; padding-top: 2px }
277
+ .swap a { font: 500 12px -apple-system, system-ui, sans-serif; color: rgba(24, 24, 27, .45);
278
+ cursor: pointer; text-decoration: underline; text-underline-offset: 3px; transition: color .15s }
279
+ .swap a:hover { color: #18181b }
280
+ .err { font-size: 12px; color: #b42318; padding: 0 4px }
281
+ .err:empty { display: none }
282
+ footer a { display: inline-flex; align-items: center; gap: 7px; font: 600 12.5px -apple-system, system-ui, sans-serif;
283
+ color: rgba(24, 24, 27, .55); text-decoration: none }
284
+ footer a > svg:first-of-type { width: 20px; height: 20px; color: #0088ff }
285
+ footer .md, footer .up { transition: color .15s, opacity .15s }
286
+ footer a:hover .md { color: #0088ff; text-decoration: underline; text-underline-offset: 3px }
287
+ footer .up { opacity: .45; margin-left: -3px }
288
+ footer a:hover .up { opacity: 1; color: #0088ff }
289
+ </style></head>
290
+ <body><main>
291
+ <div class="card">
292
+
293
+ <section id="guest" class="on">
294
+ <form method="post" action="/__mv/auth" style="display:flex;flex-direction:column;gap:14px">
295
+ <header>${appMark}<h1>${esc(name)}</h1></header>
296
+ <p class="lead">You're one step from the canvas. This space is private - enter the canvas password to step inside.</p>
297
+ <div class="err">${error ? esc(error) : ""}</div>
298
+ <input type="password" name="password" placeholder="Canvas password" autofocus autocomplete="current-password" />
299
+ <input type="hidden" name="next" />
300
+ <div class="ctawrap">
301
+ <button class="cta" type="submit" disabled>Open the canvas</button>
302
+ <span class="tip">Enter the canvas password first</span>
303
+ </div>
304
+ ${collabOn ? "<div class=\"swap\"><span style=\"font:500 12px -apple-system,system-ui,sans-serif;color:rgba(24,24,27,.45)\">Member? <a data-go=\"member\">Sign in instead</a></span></div>" : ""}
305
+ </form>
306
+ </section>
307
+ ${collabOn ? `
308
+ <section id="member">
309
+ <header>${appMark}<h1>${esc(name)}</h1></header>
310
+ <p class="lead">Welcome back - sign in with your own password. Your account already covers reading.</p>
311
+ <div class="err" id="member-err"></div>
312
+ <div class="stack">
313
+ <input type="email" id="m-email" placeholder="Email" autocomplete="email" />
314
+ <input type="password" id="m-pass" placeholder="Password" autocomplete="current-password" />
315
+ </div>
316
+ <div class="ctawrap">
317
+ <button class="cta" id="m-go" disabled>Sign in</button>
318
+ <span class="tip">Fill in email and password</span>
319
+ </div>
320
+ <div class="swap"><span style="font:500 12px -apple-system,system-ui,sans-serif;color:rgba(24,24,27,.45)">Just viewing? <a data-go="guest">Enter with the canvas password</a></span></div>
321
+ </section>
322
+
323
+ <section id="claim">
324
+ <header>${appMark}<h1>${esc(name)}</h1></header>
325
+ <p class="lead">You're invited to comment on this canvas.<br />Pick how you'll appear - comments carry your name.</p>
326
+ <div class="err" id="claim-err"></div>
327
+ <div class="stack">
328
+ <div class="chip" id="c-chip" hidden><b id="c-email"></b><span>INVITED</span></div>
329
+ <input type="password" id="c-pass" placeholder="Choose a password" autocomplete="new-password" />
330
+ </div>
331
+ <hr class="cdiv" />
332
+ <div class="idrow">
333
+ <button class="pfp" id="c-pfp" type="button" aria-label="Select your profile picture" data-tip="Select your profile picture">+</button>
334
+ <input type="file" id="c-file" accept="image/*" hidden />
335
+ <input type="text" id="c-name" placeholder="Set a display name" autocomplete="nickname" style="flex:1" />
336
+ </div>
337
+ <div class="ctawrap">
338
+ <button class="cta" id="c-go" disabled>Join the canvas</button>
339
+ <span class="tip">Password and display name still needed</span>
340
+ </div>
341
+ </section>
342
+ ` : ""}
343
+ </div>
344
+ <script>
345
+ (() => {
346
+ const $ = (id) => document.getElementById(id)
347
+ const next = document.querySelector('[name=next]')
348
+ if (next) next.value = location.hash
349
+ // the guest CTA follows its field (server-side form, client-side gating)
350
+ const gpass = document.querySelector('#guest input[type=password]')
351
+ const gbtn = document.querySelector('#guest button.cta')
352
+ gpass.addEventListener('input', () => { gbtn.disabled = !gpass.value })
353
+ ${collabOn ? `
354
+ const show = (id) => {
355
+ for (const s of document.querySelectorAll('section')) s.classList.toggle('on', s.id === id)
356
+ const f = document.querySelector('#' + id + ' input:not([hidden])')
357
+ if (f) f.focus()
358
+ }
359
+ document.addEventListener('click', (e) => {
360
+ const go = e.target.closest('[data-go]')
361
+ if (go) { e.preventDefault(); show(go.dataset.go) }
362
+ })
363
+ const api = (path, body) => fetch('/__mv/api/' + path, {
364
+ method: body === undefined ? 'GET' : 'POST',
365
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
366
+ body: body === undefined ? undefined : JSON.stringify(body),
367
+ }).then(async (r) => ({ ok: r.ok, data: await r.json().catch(() => ({})) }))
368
+
369
+ // member sign-in: their session IS gate passage - reload keeps the deep link
370
+ const gate2 = (btn, fields, tipOk) => fields.forEach((f) => f.addEventListener('input', () => {
371
+ btn.disabled = !fields.every((x) => x.value.trim())
372
+ }))
373
+ gate2($('m-go'), [$('m-email'), $('m-pass')])
374
+ const signin = async () => {
375
+ $('m-go').disabled = true
376
+ const r = await api('auth/signin', { email: $('m-email').value.trim(), password: $('m-pass').value })
377
+ if (r.ok) return location.reload()
378
+ $('member-err').textContent = r.data.error || 'sign-in failed'
379
+ $('m-go').disabled = false
380
+ }
381
+ $('m-go').addEventListener('click', signin)
382
+ $('m-pass').addEventListener('keydown', (e) => e.key === 'Enter' && !$('m-go').disabled && signin())
383
+
384
+ // invite link (#/i/<token>): straight into the claim - the token IS the credential.
385
+ // A hash-only navigation onto an invite link never reloads the page - re-run then.
386
+ addEventListener('hashchange', () => { if (/^#\\/i\\//.test(location.hash)) location.reload() })
387
+ const inv = /^#\\/i\\/([\\w-]{8,128})$/.exec(location.hash)
388
+ let avatar = ''
389
+ if (inv) {
390
+ show('claim')
391
+ api('invite-info?token=' + encodeURIComponent(inv[1])).then((r) => {
392
+ if (r.ok) { $('c-email').textContent = r.data.email; $('c-chip').hidden = false }
393
+ else $('claim-err').textContent = r.data.error || 'this invite link is invalid'
394
+ })
395
+ }
396
+ // avatar: pick, downscale to 128px client-side, preview in the circle
397
+ $('c-pfp').addEventListener('click', () => $('c-file').click())
398
+ $('c-file').addEventListener('change', () => {
399
+ const file = $('c-file').files[0]
400
+ if (!file) return
401
+ const img = new Image()
402
+ img.onload = () => {
403
+ const s = Math.min(img.width, img.height), c = document.createElement('canvas')
404
+ c.width = c.height = 128
405
+ c.getContext('2d').drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, 128, 128)
406
+ avatar = c.toDataURL('image/jpeg', .85)
407
+ const p = $('c-pfp')
408
+ p.classList.add('set'); p.style.backgroundImage = 'url(' + avatar + ')'; p.textContent = ''; p.dataset.tip = 'Change your photo'
409
+ URL.revokeObjectURL(img.src)
410
+ }
411
+ img.src = URL.createObjectURL(file)
412
+ })
413
+ gate2($('c-go'), [$('c-pass'), $('c-name')])
414
+ const claim = async () => {
415
+ $('c-go').disabled = true
416
+ const r = await api('auth/claim', { token: inv ? inv[1] : '', password: $('c-pass').value, name: $('c-name').value.trim(), avatar: avatar || undefined })
417
+ if (r.ok) return location.href = location.pathname // consumed invite leaves the URL
418
+ $('claim-err').textContent = r.data.error || 'claim failed'
419
+ $('c-go').disabled = false
420
+ }
421
+ $('c-go').addEventListener('click', claim)
422
+ $('c-name').addEventListener('keydown', (e) => e.key === 'Enter' && !$('c-go').disabled && claim())
423
+ ` : ""}
424
+ })()
425
+ <\/script>
426
+ ${meta.branding ? `<footer><a href="https://marver.design" target="_blank" rel="noopener">${MARK} <span>Powered by <span class="md">Marver.design</span></span> <svg class="up" viewBox="0 0 256 256" width="11" height="11" fill="currentColor" aria-hidden><path d="M200,64V168a8,8,0,0,1-16,0V83.31L69.66,197.66a8,8,0,0,1-11.32-11.32L172.69,72H88a8,8,0,0,1,0-16H192A8,8,0,0,1,200,64Z"/></svg></a></footer>` : ""}
427
+ </main></body></html>`);
428
+ }
429
+ const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
430
+ //#endregion
431
+ export { serve };
@@ -0,0 +1,248 @@
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ import { i as readLog, r as listBoards, t as appendEvents } from "./comments-Ba8mU600.mjs";
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ //#region src/shared/events.ts
6
+ /** Events the other side lacks, by id - the sync payload in either direction. */
7
+ function diffEvents(mine, theirIds) {
8
+ const have = new Set(theirIds);
9
+ return mine.filter((e) => !have.has(e.id));
10
+ }
11
+ /** Replay a board's events into current thread state. Deterministic: replay order is
12
+ * (ts, id) so two stores holding the same event SET always derive the same state.
13
+ * TWO passes - creates first, then everything else - so a reply whose author's clock
14
+ * ran ahead of the creator's still lands instead of being dropped forever. */
15
+ function replay(events) {
16
+ const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1));
17
+ const threads = /* @__PURE__ */ new Map();
18
+ for (const ev of ordered) {
19
+ if (ev.type !== "create") continue;
20
+ if (!ev.commentId || threads.has(ev.commentId)) continue;
21
+ threads.set(ev.commentId, {
22
+ id: ev.commentId,
23
+ board: ev.board,
24
+ nodeKey: ev.nodeKey,
25
+ frame: ev.frame,
26
+ anchor: ev.anchor,
27
+ author: ev.author,
28
+ body: ev.body,
29
+ ts: ev.ts,
30
+ resolved: false,
31
+ replies: [],
32
+ reactions: {}
33
+ });
34
+ }
35
+ for (const ev of ordered) switch (ev.type) {
36
+ case "reply": {
37
+ const t = ev.parentId ? threads.get(ev.parentId) : void 0;
38
+ if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break;
39
+ t.replies.push({
40
+ id: ev.commentId,
41
+ author: ev.author,
42
+ body: ev.body,
43
+ ts: ev.ts
44
+ });
45
+ break;
46
+ }
47
+ case "edit": {
48
+ if (!ev.commentId || ev.body === void 0) break;
49
+ const t = threads.get(ev.commentId);
50
+ if (t) {
51
+ t.body = ev.body;
52
+ break;
53
+ }
54
+ for (const th of threads.values()) {
55
+ const r = th.replies.find((r) => r.id === ev.commentId);
56
+ if (r) {
57
+ r.body = ev.body;
58
+ break;
59
+ }
60
+ }
61
+ break;
62
+ }
63
+ case "resolve": {
64
+ const t = ev.commentId ? threads.get(ev.commentId) : void 0;
65
+ if (t) {
66
+ t.resolved = true;
67
+ if (ev.addressedIn) t.addressedIn = ev.addressedIn;
68
+ }
69
+ break;
70
+ }
71
+ case "reopen": {
72
+ const t = ev.commentId ? threads.get(ev.commentId) : void 0;
73
+ if (t) {
74
+ t.resolved = false;
75
+ t.addressedIn = void 0;
76
+ }
77
+ break;
78
+ }
79
+ case "react": {
80
+ const t = ev.commentId ? threads.get(ev.commentId) : void 0;
81
+ const who = ev.author?.email;
82
+ if (!t || !who || !ev.emoji) break;
83
+ const users = t.reactions[ev.emoji] ??= [];
84
+ const at = users.indexOf(who);
85
+ if (at === -1) users.push(who);
86
+ else {
87
+ users.splice(at, 1);
88
+ if (!users.length) delete t.reactions[ev.emoji];
89
+ }
90
+ break;
91
+ }
92
+ }
93
+ return [...threads.values()];
94
+ }
95
+ //#endregion
96
+ //#region src/server/sync.ts
97
+ /**
98
+ * Dev ↔ published comment sync (SPEC-M3 §2) - one merge rule, run from the dev side.
99
+ *
100
+ * The published serve is the canonical home; the repo's design/comments/ is the
101
+ * mirror the agent reads. Each exchange is: pull remote events → union into local
102
+ * files; push the local events the remote lacks (chunked - the server unions too).
103
+ * Idempotent and retry-safe by construction, so a dropped exchange costs nothing.
104
+ *
105
+ * Credentials: design/.local/collab.json {url, token} written by `comments connect`.
106
+ * The token is a server session held by the DEV PROCESS only - pages never see it.
107
+ */
108
+ var sync_exports = /* @__PURE__ */ __exportAll({
109
+ connect: () => connect,
110
+ connectClaim: () => connectClaim,
111
+ loadCollab: () => loadCollab,
112
+ saveCollab: () => saveCollab,
113
+ syncOnce: () => syncOnce
114
+ });
115
+ const collabFile = (root) => join(root, "design", ".local", "collab.json");
116
+ function loadCollab(root) {
117
+ try {
118
+ const c = JSON.parse(readFileSync(collabFile(root), "utf8"));
119
+ return typeof c?.url === "string" && typeof c?.token === "string" ? c : null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+ function saveCollab(root, collab) {
125
+ mkdirSync(join(root, "design", ".local"), { recursive: true });
126
+ writeFileSync(collabFile(root), JSON.stringify(collab, null, 2) + "\n", { mode: 384 });
127
+ }
128
+ /** One full exchange. Returns per-board counts, or throws on auth/network failure. */
129
+ async function syncOnce(root, collab) {
130
+ const dir = join(root, "design", "comments");
131
+ const auth = { authorization: `Bearer ${collab.token}` };
132
+ const base = collab.url.replace(/\/+$/, "");
133
+ const bres = await fetch(`${base}/__mv/api/boards`, { headers: auth });
134
+ if (bres.status === 401) throw new Error("the connect token was rejected - run `comments connect` again");
135
+ if (!bres.ok) throw new Error(`published canvas answered ${bres.status} - is it up?`);
136
+ const { rights } = await bres.json();
137
+ const boards = [.../* @__PURE__ */ new Set([...Object.keys(rights), ...listBoards(dir)])];
138
+ const out = {};
139
+ const failures = [];
140
+ for (const board of boards) {
141
+ if (!(board in rights)) continue;
142
+ const res = await fetch(`${base}/__mv/api/comments/${board}`, { headers: auth });
143
+ if (!res.ok) {
144
+ failures.push(`${board}: pull ${res.status}`);
145
+ continue;
146
+ }
147
+ const { events: remote } = await res.json();
148
+ const pulled = appendEvents(dir, board, remote).length;
149
+ let pushed = 0;
150
+ if (rights[board] === "comment") {
151
+ const missing = diffEvents(readLog(dir, board), remote.map((e) => e.id));
152
+ for (let i = 0; i < missing.length; i += 100) {
153
+ const r = await fetch(`${base}/__mv/api/comments/${board}`, {
154
+ method: "POST",
155
+ headers: {
156
+ ...auth,
157
+ "content-type": "application/json"
158
+ },
159
+ body: JSON.stringify({ events: missing.slice(i, i + 100) })
160
+ });
161
+ if (r.ok) pushed += (await r.json()).accepted ?? 0;
162
+ else failures.push(`${board}: push ${r.status} ${(await r.json().catch(() => null))?.error ?? ""}`.trim());
163
+ }
164
+ }
165
+ out[board] = {
166
+ pulled,
167
+ pushed
168
+ };
169
+ }
170
+ if (failures.length) throw new Error(`sync incomplete - ${failures.join(" · ")}`);
171
+ return out;
172
+ }
173
+ /** The account endpoints live BEHIND the canvas gate (the outer READ boundary), so
174
+ * connecting passes the gate first when a canvas password is set. */
175
+ async function gateCookie(base, canvasPassword) {
176
+ if (!canvasPassword) return "";
177
+ const cookie = (await fetch(`${base}/__mv/auth`, {
178
+ method: "POST",
179
+ redirect: "manual",
180
+ headers: { "content-type": "application/x-www-form-urlencoded" },
181
+ body: `password=${encodeURIComponent(canvasPassword)}&next=`
182
+ })).headers.getSetCookie?.().find((c) => c.startsWith("mv_a=")) ?? "";
183
+ const tok = /^mv_a=([^;]+)/.exec(cookie)?.[1];
184
+ if (!tok) throw new Error("the canvas password was not accepted");
185
+ return `mv_a=${tok}`;
186
+ }
187
+ async function sessionFrom(res, ctx) {
188
+ if (!res.ok) {
189
+ const err = (await res.json().catch(() => null))?.error;
190
+ throw new Error(err ?? `${ctx} failed (${res.status})`);
191
+ }
192
+ const cookie = res.headers.getSetCookie?.().find((c) => c.startsWith("mv_s=")) ?? "";
193
+ const token = /^mv_s=([\w-]+)/.exec(cookie)?.[1];
194
+ if (!token) throw new Error("the canvas did not issue a session - is collaboration enabled on it (MARVER_DATA_DIR)?");
195
+ return token;
196
+ }
197
+ /** Sign in against a published canvas and persist the device credential. The account's
198
+ * identity rides along: locally-born events carry it as their author snapshot (the
199
+ * server validates the claim against the session - it never rewrites events). */
200
+ async function connect(root, url, email, password, canvasPassword) {
201
+ const base = url.replace(/\/+$/, "");
202
+ const gate = await gateCookie(base, canvasPassword);
203
+ const res = await fetch(`${base}/__mv/api/auth/signin`, {
204
+ method: "POST",
205
+ headers: {
206
+ "content-type": "application/json",
207
+ ...gate ? { cookie: gate } : {}
208
+ },
209
+ body: JSON.stringify({
210
+ email,
211
+ password
212
+ })
213
+ });
214
+ const token = await sessionFrom(res, "sign-in");
215
+ const user = (await res.clone().json().catch(() => null))?.user;
216
+ saveCollab(root, {
217
+ url: base,
218
+ token,
219
+ email: user?.email ?? email,
220
+ name: user?.name
221
+ });
222
+ }
223
+ /** Claim an invite from the CLI (the dev-first path - no published UI needed). */
224
+ async function connectClaim(root, url, invite, profile, canvasPassword) {
225
+ const base = url.replace(/\/+$/, "");
226
+ const gate = await gateCookie(base, canvasPassword);
227
+ const res = await fetch(`${base}/__mv/api/auth/claim`, {
228
+ method: "POST",
229
+ headers: {
230
+ "content-type": "application/json",
231
+ ...gate ? { cookie: gate } : {}
232
+ },
233
+ body: JSON.stringify({
234
+ token: invite,
235
+ ...profile
236
+ })
237
+ });
238
+ const token = await sessionFrom(res, "claim");
239
+ const user = (await res.clone().json().catch(() => null))?.user;
240
+ saveCollab(root, {
241
+ url: base,
242
+ token,
243
+ email: user?.email,
244
+ name: user?.name ?? profile.name
245
+ });
246
+ }
247
+ //#endregion
248
+ export { sync_exports as a, syncOnce as i, connectClaim as n, replay as o, loadCollab as r, connect as t };