@marver-design/marver 0.3.1 → 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.
@@ -128,12 +128,126 @@ function apiMiddleware(root) {
128
128
  return json(res, 200, { sha256: hash(next2) });
129
129
  }
130
130
  }
131
+ if (path === "me" && req.method === "GET") return json(res, 200, {
132
+ user: localProfile(root),
133
+ role: "owner",
134
+ local: true
135
+ });
136
+ if (path === "profile" && req.method === "POST") {
137
+ const raw = await readBody(req);
138
+ if (raw == null) return json(res, 400, { error: "body too large" });
139
+ let b;
140
+ try {
141
+ b = JSON.parse(raw);
142
+ } catch {
143
+ return json(res, 400, { error: "malformed JSON" });
144
+ }
145
+ const dir = join(root, "design", ".local");
146
+ mkdirSync(dir, { recursive: true });
147
+ const prof = {
148
+ ...localProfile(root),
149
+ ...typeof b.name === "string" && b.name.trim() ? { name: b.name.trim() } : {},
150
+ ...typeof b.email === "string" ? { email: b.email.trim() } : {},
151
+ ...typeof b.avatar === "string" ? { avatar: b.avatar || void 0 } : {}
152
+ };
153
+ atomicWrite(join(dir, "profile.json"), JSON.stringify(prof, null, 2) + "\n");
154
+ return json(res, 200, { user: prof });
155
+ }
156
+ if (path === "boards.rights" && req.method === "GET") {
157
+ const { listBoards } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
158
+ return json(res, 200, {
159
+ local: true,
160
+ stored: listBoards(join(root, "design", "comments"))
161
+ });
162
+ }
163
+ const cm = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
164
+ if (cm) {
165
+ const { appendEvents, readLog } = await import("./comments-Ba8mU600.mjs").then((n) => n.n);
166
+ const dir = join(root, "design", "comments");
167
+ if (req.method === "GET") return json(res, 200, { events: readLog(dir, cm[1]) });
168
+ if (req.method === "POST") {
169
+ const raw = await readBody(req);
170
+ if (raw == null) return json(res, 400, { error: "body too large" });
171
+ let b;
172
+ try {
173
+ b = JSON.parse(raw);
174
+ } catch {
175
+ return json(res, 400, { error: "malformed JSON" });
176
+ }
177
+ const incoming = Array.isArray(b.events) ? b.events : [];
178
+ const me = localProfile(root);
179
+ const stamped = incoming.map((ev) => ({
180
+ ...ev,
181
+ board: ev.board ?? cm[1],
182
+ author: ev.author ?? ([
183
+ "create",
184
+ "reply",
185
+ "react",
186
+ "edit"
187
+ ].includes(ev.type) ? me : void 0)
188
+ }));
189
+ const fresh = appendEvents(dir, cm[1], stamped);
190
+ backgroundPush(root);
191
+ return json(res, 200, { accepted: fresh.length });
192
+ }
193
+ }
194
+ if (path === "sync" && req.method === "POST") {
195
+ const { loadCollab, syncOnce } = await import("./sync-CkBk-tUk.mjs").then((n) => n.a);
196
+ const collab = loadCollab(root);
197
+ if (!collab) return json(res, 200, { connected: false });
198
+ try {
199
+ return json(res, 200, {
200
+ connected: true,
201
+ boards: await syncOnce(root, collab)
202
+ });
203
+ } catch (err) {
204
+ return json(res, 502, {
205
+ connected: true,
206
+ error: err.message
207
+ });
208
+ }
209
+ }
131
210
  json(res, 404, { error: "unknown endpoint" });
132
211
  } catch (err) {
133
212
  json(res, 500, { error: err.message });
134
213
  }
135
214
  };
136
215
  }
216
+ function localProfile(root) {
217
+ try {
218
+ const c = JSON.parse(readFileSync(join(root, "design", ".local", "collab.json"), "utf8"));
219
+ if (typeof c?.email === "string" && c.email) return {
220
+ email: c.email,
221
+ name: c.name ?? "Designer"
222
+ };
223
+ } catch {}
224
+ try {
225
+ const p = JSON.parse(readFileSync(join(root, "design", ".local", "profile.json"), "utf8"));
226
+ if (typeof p?.name === "string") return {
227
+ email: p.email ?? "",
228
+ name: p.name,
229
+ avatar: p.avatar
230
+ };
231
+ } catch {}
232
+ return {
233
+ email: "",
234
+ name: "Designer"
235
+ };
236
+ }
237
+ let pushTimer = null;
238
+ /** Debounced fire-and-forget push after a local write; failures are silent - the
239
+ * 30s sync loop is the reliability layer, this is just latency. */
240
+ function backgroundPush(root) {
241
+ if (pushTimer) return;
242
+ pushTimer = setTimeout(async () => {
243
+ pushTimer = null;
244
+ try {
245
+ const { loadCollab, syncOnce } = await import("./sync-CkBk-tUk.mjs").then((n) => n.a);
246
+ const collab = loadCollab(root);
247
+ if (collab) await syncOnce(root, collab);
248
+ } catch {}
249
+ }, 1500);
250
+ }
137
251
  //#endregion
138
252
  //#region src/server/routes.ts
139
253
  /**
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ export { __exportAll as t };
@@ -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 };