@marver-design/marver 0.8.0 → 0.9.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 (52) hide show
  1. package/CHANGELOG.md +168 -5
  2. package/README.md +73 -16
  3. package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
  4. package/dist/{build-B8z902TW.mjs → build-D9gimz6K.mjs} +5 -5
  5. package/dist/cli.mjs +16 -7
  6. package/dist/{collab-pLGzSvm5.mjs → collab-s3k5byM1.mjs} +3 -3
  7. package/dist/{comments-DSwMhdbZ.mjs → comments-BZBKhKRO.mjs} +2 -2
  8. package/dist/{comments-BrpC86Kf.mjs → comments-J06jqCVV.mjs} +3 -3
  9. package/dist/{daemon-C-huGHAM.mjs → daemon-2Lft_lVV.mjs} +60 -66
  10. package/dist/{dev-BYm9ebfN.mjs → dev-CTtkqVo_.mjs} +21 -7
  11. package/dist/{init-DrLomSWq.mjs → init-8Giknvy1.mjs} +35 -7
  12. package/dist/ledger-BgA7nQoH.mjs +103 -0
  13. package/dist/{manifest-D3eaARf4.mjs → manifest-DJHU7qfu.mjs} +144 -19
  14. package/dist/{plugin-BVFuRfEo.mjs → plugin-BrBWk2Qn.mjs} +41 -9
  15. package/dist/{serve-CwAfayJk.mjs → serve-CZqPnj19.mjs} +32 -8
  16. package/dist/{sync-Ch4Bymb1.mjs → sync-BJKKmy1n.mjs} +2 -2
  17. package/dist/work-CLrmY-vQ.mjs +97 -0
  18. package/dist/work-lzC-lPY0.mjs +76 -0
  19. package/package.json +17 -2
  20. package/src/client/const.ts +1 -1
  21. package/src/client/content/diagram.tsx +1 -1
  22. package/src/client/content/index.tsx +2 -2
  23. package/src/client/content/md.ts +1 -1
  24. package/src/client/content/palette.ts +2 -2
  25. package/src/client/frame-host/bridge.js +1 -1
  26. package/src/client/frame-host/inspect.js +2 -2
  27. package/src/client/frame-host/serialize.ts +2 -2
  28. package/src/client/shell/App.tsx +79 -22
  29. package/src/client/shell/Comments.tsx +24 -18
  30. package/src/client/shell/Play.tsx +2 -2
  31. package/src/client/shell/canvas/Canvas.tsx +1 -1
  32. package/src/client/shell/canvas/FrameNode.tsx +16 -11
  33. package/src/client/shell/canvas/snapshots.ts +3 -3
  34. package/src/client/shell/comments-store.ts +115 -42
  35. package/src/client/shell/hash.ts +2 -2
  36. package/src/client/shell/icons.tsx +1 -1
  37. package/src/client/shell/keys.ts +39 -0
  38. package/src/client/shell/mentions.ts +1 -1
  39. package/src/client/shell/perf.ts +1 -1
  40. package/src/client/shell/store.ts +99 -39
  41. package/src/client/shell/styles.css +16 -16
  42. package/src/client/shell/tidy.ts +5 -5
  43. package/src/client/stage/main.tsx +2 -2
  44. package/src/shared/events.ts +2 -2
  45. package/src/shared/utm.ts +22 -0
  46. package/templates/AGENTS-embedded.md +49 -2
  47. package/templates/AGENTS-studio.md +49 -2
  48. package/templates/instructions/configure.md +6 -1
  49. package/templates/instructions/jam.md +29 -9
  50. package/templates/instructions/publish.md +1 -1
  51. package/templates/instructions/welcome.md +7 -1
  52. package/dist/ledger-wFvEIEGi.mjs +0 -64
@@ -1,6 +1,6 @@
1
1
  import { r as PKG, t as CONTENT_WIDTH } from "./cli.mjs";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
- import { join, relative, sep } from "node:path";
2
+ import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
+ import { delimiter, isAbsolute, join, relative, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { createHash } from "node:crypto";
6
6
  //#region src/server/detect.ts
@@ -125,6 +125,68 @@ function firstExisting(root, candidates) {
125
125
  return null;
126
126
  }
127
127
  //#endregion
128
+ //#region src/server/jam/agent.ts
129
+ /**
130
+ * Which coding agent drives this repo.
131
+ *
132
+ * Live Jam is ON by default, so this is the answer to "on by default with WHAT" - asked
133
+ * once by `init` (which writes the answer into design/config.ts, where it is visible and
134
+ * editable) and again at every dev boot, so a workspace that predates the block - or a
135
+ * human who switched tools - still jams without editing anything.
136
+ *
137
+ * The tool RUNNING us wins: `init` is usually run BY the agent, so its env markers are
138
+ * evidence rather than a guess. Whatever wins must still be on PATH - the daemon has to
139
+ * spawn it.
140
+ */
141
+ /** Ordered by preference when a machine has both installed and neither is running us. */
142
+ const AGENTS = ["claude", "codex"];
143
+ /** Env vars each CLI sets in the processes it spawns. Deliberately NOT CODEX_HOME or
144
+ * ANTHROPIC_API_KEY: those are configuration a human exports by hand, not evidence that
145
+ * the tool is running right now. */
146
+ const MARKERS = {
147
+ claude: ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"],
148
+ codex: ["CODEX_SANDBOX", "CODEX_THREAD_ID"]
149
+ };
150
+ /** Is `cmd` an executable FILE on PATH? A direct stat per PATH entry - no spawn, no shell,
151
+ * so this is safe to call on every dev boot. Two deliberate narrowings, both in service of
152
+ * "found means spawnable", because arming an agent that cannot run is worse than staying off:
153
+ *
154
+ * - `isFile`, because a directory carries the execute bit too (it means "traversable"), so an
155
+ * access check alone would call a folder named `claude` an agent.
156
+ * - the bare name only, no PATHEXT: the daemon spawns without a shell, and Node cannot run a
157
+ * Windows `.cmd`/`.bat` shim that way. Finding one would arm a job that fails on every run.
158
+ *
159
+ * Only ABSOLUTE PATH entries count. An empty entry means the current directory on POSIX, and
160
+ * a relative one (`.`, `bin`) resolves against it too - and the current directory is the repo
161
+ * that was just opened, so a `claude` binary shipped inside it is precisely what must never
162
+ * be found and spawned. */
163
+ function onPath(cmd, env = process.env) {
164
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
165
+ if (!isAbsolute(dir)) continue;
166
+ try {
167
+ const file = join(dir, cmd);
168
+ if (statSync(file).isFile()) {
169
+ accessSync(file, constants.X_OK);
170
+ return true;
171
+ }
172
+ } catch {}
173
+ }
174
+ return false;
175
+ }
176
+ /** The agent to jam with, or undefined when this machine has none installed.
177
+ *
178
+ * One knowingly-imperfect case: agents nest, and env is inherited, so a codex run started
179
+ * FROM Claude Code carries both marker families and this picks claude. Env has no depth to
180
+ * read, and walking the process tree to find the nearest agent ancestor costs more than the
181
+ * case is worth - so the answer is made visible instead of clever: `init` prints the agent
182
+ * it chose and writes it into design/config.ts, and instructions/jam.md has the agent confirm
183
+ * that line names the tool it actually is. One word to correct, once per repo. */
184
+ function detectAgent(env = process.env) {
185
+ const running = AGENTS.find((a) => MARKERS[a].some((k) => env[k]));
186
+ if (running && onPath(running, env)) return running;
187
+ return AGENTS.find((a) => onPath(a, env));
188
+ }
189
+ //#endregion
128
190
  //#region src/server/config.ts
129
191
  const DEFAULTS = {
130
192
  mode: "studio",
@@ -155,7 +217,10 @@ const DEFAULTS = {
155
217
  /** Load design/config.ts via native TS import (Node >= 22.18). Missing or broken fields fall back to defaults. */
156
218
  async function loadConfig(root) {
157
219
  const file = join(root, "design", "config.ts");
158
- if (!existsSync(file)) return { ...DEFAULTS };
220
+ if (!existsSync(file)) return {
221
+ ...DEFAULTS,
222
+ ...jamFields(void 0)
223
+ };
159
224
  try {
160
225
  const user = (await import(`${pathToFileURL(file).href}?t=${Date.now()}`)).default ?? {};
161
226
  return {
@@ -170,11 +235,14 @@ async function loadConfig(root) {
170
235
  name: typeof user.share?.name === "string" ? user.share.name : void 0,
171
236
  logo: typeof user.share?.logo === "string" ? user.share.logo : void 0
172
237
  },
173
- jam: validJam(user.jam)
238
+ ...jamFields(user.jam)
174
239
  };
175
240
  } catch (err) {
176
- console.error(`[marver] design/config.ts failed to load, using defaults:\n ${err.message}`);
177
- return { ...DEFAULTS };
241
+ console.error(`[marver] design/config.ts failed to load, using defaults and leaving Live Jam OFF:\n ${err.message}`);
242
+ return {
243
+ ...DEFAULTS,
244
+ jamOff: "unreadable"
245
+ };
178
246
  }
179
247
  }
180
248
  const validDim = (n) => typeof n === "number" && Number.isFinite(n) && n >= 1 && n <= 2e4;
@@ -184,20 +252,77 @@ function validPort(n) {
184
252
  function validZoom(n) {
185
253
  return typeof n === "number" && Number.isFinite(n) && n >= .1 && n <= 10 ? n : null;
186
254
  }
187
- /** Normalize the jam block. An unknown/missing `agent` means Live Jam stays OFF (returns undefined),
188
- * so a stray `jam: {}` never silently arms the daemon. Other fields fall back to lean defaults. */
189
- function validJam(v) {
190
- if (!v || typeof v !== "object") return void 0;
191
- const j = v;
192
- if (j.agent !== "claude" && j.agent !== "codex") return void 0;
193
- const concurrency = typeof j.concurrency === "number" && Number.isInteger(j.concurrency) && j.concurrency >= 1 && j.concurrency <= 16 ? j.concurrency : 3;
255
+ /** Six frames at once is what a jam actually feels like - at 3, half of a multi-frame
256
+ * ask sat waiting on the other half while the human watched. */
257
+ const DEFAULT_CONCURRENCY = 6;
258
+ /** A config value, printable in a warning. Never throws - a formatter that can crash inside an
259
+ * error path (JSON.stringify does, on a BigInt) turns a clear message into a mystery. */
260
+ const show = (v) => {
261
+ try {
262
+ return JSON.stringify(v) ?? String(v);
263
+ } catch {
264
+ return String(v);
265
+ }
266
+ };
267
+ /** A `{...}` written by hand, not a Date/Map/class instance that merely types as "object". */
268
+ const plainObject = (v) => {
269
+ if (!v || typeof v !== "object") return false;
270
+ const proto = Object.getPrototypeOf(v);
271
+ return proto === Object.prototype || proto === null;
272
+ };
273
+ /** The agent to arm with - or, when the human NAMED one we cannot use, why not (said once,
274
+ * here, because a bad `jam.agent` is a config-file error like any other).
275
+ *
276
+ * A named agent is never quietly swapped for another: running a different tool than the one
277
+ * asked for is worse than not running. Nor is one armed that cannot be spawned - that would
278
+ * claim every @marver mention, fail it, and never retry. */
279
+ function resolveAgent(named) {
280
+ if (named === void 0) return detectAgent() ?? "no-agent";
281
+ if (named !== "claude" && named !== "codex") {
282
+ console.warn(`[marver] design/config.ts: jam.agent ${show(named)} is not an agent marver can spawn - Live Jam is off. Use "claude" or "codex".`);
283
+ return "bad-agent";
284
+ }
285
+ if (!onPath(named)) {
286
+ console.warn(`[marver] design/config.ts names jam.agent "${named}", which is not on PATH - Live Jam is off until it is installed.`);
287
+ return "bad-agent";
288
+ }
289
+ return named;
290
+ }
291
+ /** Resolve the jam block. Live Jam is ON by default: an absent or partial `jam` resolves
292
+ * to whatever agent CLI this machine has, so a fresh workspace jams with nothing
293
+ * configured and an old one needs no re-init. `jam: false` is the off switch. */
294
+ function resolveJam(v) {
295
+ if (v === false) return "opted-out";
296
+ const shape = v === void 0 || v === true ? {} : typeof v === "string" ? { agent: v } : plainObject(v) ? v : null;
297
+ if (!shape) {
298
+ console.warn(`[marver] design/config.ts: jam must be false, an agent name, or an options object - got ${show(v)}. Live Jam is off.`);
299
+ return "bad-agent";
300
+ }
301
+ const j = shape;
302
+ const agent = resolveAgent(j.agent);
303
+ if (agent === "no-agent" || agent === "bad-agent") return agent;
304
+ const c = j.concurrency;
194
305
  return {
195
- agent: j.agent,
196
- concurrency,
306
+ agent,
307
+ concurrency: typeof c === "number" && Number.isInteger(c) && c >= 1 && c <= 16 ? c : DEFAULT_CONCURRENCY,
197
308
  subagents: j.subagents !== false,
198
309
  proactive: j.proactive === true
199
310
  };
200
311
  }
312
+ /** Exactly one of `jam` (armed) or `jamOff` (why not) - so the dev server can speak up about
313
+ * the one off-state nothing has reported yet, and stay quiet about the rest. BOTH keys are
314
+ * always written: these spread over the user's own `jam`, and a raw `jam: false` reaching the
315
+ * server as config would arm the daemon against a truthy object check. */
316
+ function jamFields(v) {
317
+ const r = resolveJam(v);
318
+ return typeof r === "string" ? {
319
+ jam: void 0,
320
+ jamOff: r
321
+ } : {
322
+ jam: r,
323
+ jamOff: void 0
324
+ };
325
+ }
201
326
  function validViewports(v) {
202
327
  if (!v || typeof v !== "object") return null;
203
328
  const out = {};
@@ -214,7 +339,7 @@ function validViewports(v) {
214
339
  //#region src/server/manifest.ts
215
340
  const FRAME_EXT = /\.(tsx|jsx|html)$/;
216
341
  const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
217
- /** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted (spec §6). */
342
+ /** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted. */
218
343
  function extractMeta(src) {
219
344
  const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
220
345
  if (!m) return {};
@@ -238,7 +363,7 @@ function extractMeta(src) {
238
363
  if (intent) out.intent = intent;
239
364
  return out;
240
365
  }
241
- /** Content-frame detection (SPEC-026): LEXICAL by convention - the frame file
366
+ /** Content-frame detection: LEXICAL by convention - the frame file
242
367
  * itself imports PKG/content (an import specifier scan, so "<Diagram" inside a
243
368
  * string in a UI frame can never misbadge it; barrels/re-exports are not
244
369
  * detected - meta.intent is the taught path and always works). Returns the
@@ -358,7 +483,7 @@ function scanFrames(root) {
358
483
  })).sort((a, b) => a.name.localeCompare(b.name))
359
484
  };
360
485
  }
361
- /** Variant groups (SPEC-023 §1). A group = 2+ frames in one DIRECTORY whose basenames
486
+ /** Variant groups. A group = 2+ frames in one DIRECTORY whose basenames
362
487
  * are letter-prefixed (`a-terminal`), or frames declaring `meta.of`. Group id = the
363
488
  * directory's id prefix (or meta.of); variant key = the letter (or meta.variant).
364
489
  * Nested directories scope alternatives inside a busy scene (checkout/payment/a-card).
@@ -439,4 +564,4 @@ function writeManifest(root, manifest) {
439
564
  }
440
565
  const hash = (s) => createHash("sha256").update(s).digest("hex");
441
566
  //#endregion
442
- export { DEFAULTS as a, readJson as c, writeManifest as i, hash as n, loadConfig as o, scanFrames as r, detectHost as s, affectedFrameIds as t };
567
+ export { DEFAULTS as a, detectHost as c, writeManifest as i, readJson as l, hash as n, loadConfig as o, scanFrames as r, detectAgent as s, affectedFrameIds as t };
@@ -1,6 +1,6 @@
1
1
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
2
2
  import { n as localProfile, t as isConnected } from "./profile-BkiWglVE.mjs";
3
- import { i as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-D3eaARf4.mjs";
3
+ import { i as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-DJHU7qfu.mjs";
4
4
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, resolve, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -14,7 +14,7 @@ function cookie(req, name) {
14
14
  const m = new RegExp(`(?:^|;\\s*)${name}=([^;]+)`).exec(String(req.headers.cookie ?? ""));
15
15
  return m ? m[1] : "";
16
16
  }
17
- /** The owner gate (SPEC-live-jam §1). The dev server is 127.0.0.1-only, so the live threat is a
17
+ /** The owner gate. The dev server is 127.0.0.1-only, so the live threat is a
18
18
  * cross-origin drive-by page firing a POST at localhost. Two defenses, mirroring the published
19
19
  * side (collab.ts:206): a double-submit cookie (mv_c is JS-readable same-origin, so a cross-origin
20
20
  * page cannot read it to echo x-mv-c), plus an Origin allowlist. Only a POST that passes this is
@@ -185,7 +185,7 @@ function apiMiddleware(root) {
185
185
  }
186
186
  const dir = join(root, "design", ".local");
187
187
  mkdirSync(dir, { recursive: true });
188
- const { validAvatar } = await import("./collab-pLGzSvm5.mjs");
188
+ const { validAvatar } = await import("./collab-s3k5byM1.mjs");
189
189
  let cur = {};
190
190
  try {
191
191
  cur = JSON.parse(readFileSync(join(dir, "profile.json"), "utf8"));
@@ -199,8 +199,40 @@ function apiMiddleware(root) {
199
199
  atomicWrite(join(dir, "profile.json"), JSON.stringify(prof, null, 2) + "\n");
200
200
  return json(res, 200, { user: localProfile(root) });
201
201
  }
202
+ if (path === "work") {
203
+ const { workActivity, readDevInfo, WORK_TTL_DEFAULT, WORK_TTL_MAX } = await import("./work-CLrmY-vQ.mjs");
204
+ const tokenOk = () => {
205
+ const t = readDevInfo(root)?.token;
206
+ return !!t && req.headers["x-mv-work"] === t;
207
+ };
208
+ if (req.method === "GET") {
209
+ if (!ownerGated(req) && !tokenOk()) return json(res, 403, { error: "forbidden" });
210
+ return json(res, 200, { frames: workActivity.active() });
211
+ }
212
+ if (req.method === "POST") {
213
+ if (!ownerGated(req) && !tokenOk()) return json(res, 403, { error: "forbidden" });
214
+ const raw = await readBody(req);
215
+ if (raw == null) return json(res, 400, { error: "body too large" });
216
+ let b;
217
+ try {
218
+ b = JSON.parse(raw);
219
+ } catch {
220
+ return json(res, 400, { error: "malformed JSON" });
221
+ }
222
+ const frames = Array.isArray(b.frames) ? b.frames.filter((f) => typeof f === "string" && f) : [];
223
+ const on = b.on !== false;
224
+ if (!on && b.all === true) {
225
+ workActivity.clearAll("cli");
226
+ return json(res, 200, { frames: workActivity.active() });
227
+ }
228
+ if (!frames.length) return json(res, 400, { error: "frames required" });
229
+ const ttl = Math.min(Math.max(Number(b.ttlMs) || WORK_TTL_DEFAULT, 1e4), WORK_TTL_MAX);
230
+ for (const f of frames) on ? workActivity.mark(f, ttl, "cli") : workActivity.clear(f, "cli");
231
+ return json(res, 200, { frames: workActivity.active() });
232
+ }
233
+ }
202
234
  if (path === "boards.rights" && req.method === "GET") {
203
- const { listBoards } = await import("./comments-DSwMhdbZ.mjs");
235
+ const { listBoards } = await import("./comments-BZBKhKRO.mjs");
204
236
  return json(res, 200, {
205
237
  local: true,
206
238
  stored: listBoards(join(root, "design", "comments"))
@@ -208,7 +240,7 @@ function apiMiddleware(root) {
208
240
  }
209
241
  const cm = /^comments\/([a-z0-9][a-z0-9-]*)$/.exec(path);
210
242
  if (cm) {
211
- const { appendEvents, readLog } = await import("./comments-DSwMhdbZ.mjs");
243
+ const { appendEvents, readLog } = await import("./comments-BZBKhKRO.mjs");
212
244
  const dir = join(root, "design", "comments");
213
245
  if (req.method === "GET") return json(res, 200, { events: readLog(dir, cm[1]) });
214
246
  if (req.method === "POST") {
@@ -237,14 +269,14 @@ function apiMiddleware(root) {
237
269
  };
238
270
  });
239
271
  const fresh = appendEvents(dir, cm[1], stamped);
240
- const { record } = await import("./ledger-wFvEIEGi.mjs").then((n) => n.n);
272
+ const { record } = await import("./ledger-BgA7nQoH.mjs").then((n) => n.n);
241
273
  for (const ev of fresh) if (ev.type === "create" || ev.type === "reply") record(root, cm[1], ev.id);
242
274
  backgroundPush(root);
243
275
  return json(res, 200, { accepted: fresh.length });
244
276
  }
245
277
  }
246
278
  if (path === "sync" && req.method === "POST") {
247
- const { loadCollab, syncOnce } = await import("./sync-Ch4Bymb1.mjs");
279
+ const { loadCollab, syncOnce } = await import("./sync-BJKKmy1n.mjs");
248
280
  const collab = loadCollab(root);
249
281
  if (!collab) return json(res, 200, { connected: false });
250
282
  try {
@@ -273,7 +305,7 @@ function backgroundPush(root) {
273
305
  pushTimer = setTimeout(async () => {
274
306
  pushTimer = null;
275
307
  try {
276
- const { loadCollab, syncOnce } = await import("./sync-Ch4Bymb1.mjs");
308
+ const { loadCollab, syncOnce } = await import("./sync-BJKKmy1n.mjs");
277
309
  const collab = loadCollab(root);
278
310
  if (collab) await syncOnce(root, collab);
279
311
  } catch {}
@@ -454,7 +486,7 @@ function marverPlugin(ctx) {
454
486
  zoomSpeed: config.zoomSpeed,
455
487
  noTheme: themeFile() == null,
456
488
  setup: setupPending,
457
- projectName: basename(root)
489
+ projectName: config.share.name || basename(root)
458
490
  })}`;
459
491
  }
460
492
  if (id === "\0virtual:sh-data") return "export default null";
@@ -3,10 +3,29 @@ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
3
  import { extname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
5
5
  import { createServer } from "node:http";
6
+ //#region src/shared/utm.ts
7
+ /** Automatic attribution for the powered-by links: every canvas tags its marver.design
8
+ * link so site analytics can see WHICH canvas sent a visitor - zero user setup.
9
+ * Conventional UTM semantics (stable groupings first, the variable last):
10
+ * utm_source = the surface class ('published-canvas' | 'dev-canvas')
11
+ * utm_medium = the link unit ('powered-by')
12
+ * utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
13
+ * utm_content = the placement ('gate' badge | 'shell' wordmark)
14
+ */
15
+ function poweredByUrl(canvasName, source, content) {
16
+ const slug = (canvasName ?? "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
17
+ return `https://marver.design/?${new URLSearchParams({
18
+ utm_source: source,
19
+ utm_medium: "powered-by",
20
+ ...slug ? { utm_campaign: slug } : {},
21
+ utm_content: content
22
+ }).toString()}`;
23
+ }
24
+ //#endregion
6
25
  //#region src/server/serve.ts
7
26
  /**
8
27
  * `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
28
+ * password gate. No MARVER_PASSWORD → plain static serving. With it, the
10
29
  * bundle is never sent pre-auth: every unauthenticated request gets the gate page, auth
11
30
  * is a POST compare + an HMAC-signed 30-day cookie keyed off the password itself (all
12
31
  * instances agree, nothing stored server-side).
@@ -49,9 +68,9 @@ async function serve(root, portFlag) {
49
68
  let bearerCheck = null;
50
69
  let sessionCheck = null;
51
70
  if (process.env.MARVER_DATA_DIR) {
52
- const { collabHandler } = await import("./collab-pLGzSvm5.mjs");
53
- const { appendEvents, readLog } = await import("./comments-DSwMhdbZ.mjs");
54
- const { dataDir } = await import("./comments-DSwMhdbZ.mjs");
71
+ const { collabHandler } = await import("./collab-s3k5byM1.mjs");
72
+ const { appendEvents, readLog } = await import("./comments-BZBKhKRO.mjs");
73
+ const { dataDir } = await import("./comments-BZBKhKRO.mjs");
55
74
  const dir = dataDir();
56
75
  const seedDir = join(dist, "design", "comments");
57
76
  if (existsSync(seedDir)) for (const f of readdirSync(seedDir)) {
@@ -60,7 +79,7 @@ async function serve(root, portFlag) {
60
79
  appendEvents(join(dir, "comments"), board, readLog(seedDir, board));
61
80
  }
62
81
  collab = collabHandler(dir, dist);
63
- const { loadStore, createInvite, normEmail, sessionUser } = await import("./auth-B36fMCM3.mjs");
82
+ const { loadStore, createInvite, normEmail, sessionUser } = await import("./auth-KQ9Aj-nB.mjs");
64
83
  bearerCheck = (token) => sessionUser(dir, token);
65
84
  sessionCheck = (req) => {
66
85
  const tok = /(?:^|;\s*)mv_s=([\w-]+)/.exec(String(req.headers.cookie ?? ""))?.[1];
@@ -128,7 +147,12 @@ async function serve(root, portFlag) {
128
147
  if (!bearerOk && !preGate) return gate(res, meta, !!collab);
129
148
  }
130
149
  }
131
- if (collab && url.pathname.startsWith("/__mv/api/")) {
150
+ if (url.pathname.startsWith("/__mv/api/")) {
151
+ if (!collab) {
152
+ res.statusCode = 404;
153
+ res.setHeader("content-type", "application/json");
154
+ return res.end("{\"error\":\"collaboration is not enabled on this canvas\"}");
155
+ }
132
156
  collab(req, res, url).then((handled) => {
133
157
  if (!handled) {
134
158
  res.statusCode = 404;
@@ -188,7 +212,7 @@ const MARK_AT = (size) => `<svg viewBox="0 0 256 256" width="${size}" height="${
188
212
  const MARK = MARK_AT(17);
189
213
  const MARK_LG = MARK_AT(24);
190
214
  /** 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):
215
+ * shell's exact token language. Three doors, one credential each:
192
216
  * guests pay the canvas password, members sign in with their OWN password, and an
193
217
  * invite link (#/i/<token>) opens straight into the claim. The member and claim
194
218
  * states exist only on collaboration canvases - a static canvas keeps one field. */
@@ -423,7 +447,7 @@ ${collabOn ? `
423
447
  ` : ""}
424
448
  })()
425
449
  <\/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>` : ""}
450
+ ${meta.branding ? `<footer><a href="${poweredByUrl(meta.name, "published-canvas", "gate")}" 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
451
  </main></body></html>`);
428
452
  }
429
453
  const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
@@ -1,10 +1,10 @@
1
1
  import { t as diffEvents } from "./events-BMtBvvgU.mjs";
2
- import { appendEvents, listBoards, readLog } from "./comments-DSwMhdbZ.mjs";
2
+ import { appendEvents, listBoards, readLog } from "./comments-BZBKhKRO.mjs";
3
3
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  //#region src/server/sync.ts
6
6
  /**
7
- * Dev ↔ published comment sync (SPEC-M3 §2) - one merge rule, run from the dev side.
7
+ * Dev ↔ published comment sync - one merge rule, run from the dev side.
8
8
  *
9
9
  * The published serve is the canonical home; the repo's design/comments/ is the
10
10
  * mirror the agent reads. Each exchange is: pull remote events → union into local
@@ -0,0 +1,97 @@
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ //#region src/server/jam/activity.ts
5
+ function createActivity(ttlMs = 9e4) {
6
+ const m = /* @__PURE__ */ new Map();
7
+ const cbs = [];
8
+ const frames = () => [...new Set([...m.keys()].map((k) => k.slice(k.indexOf("|") + 1)))];
9
+ const emit = () => {
10
+ const f = frames();
11
+ for (const cb of cbs) cb(f);
12
+ };
13
+ return {
14
+ mark(frame, ttl, src = "cli") {
15
+ if (!frame) return;
16
+ m.set(`${src}|${frame}`, Date.now() + (ttl ?? ttlMs));
17
+ emit();
18
+ },
19
+ clear(frame, src = "cli") {
20
+ if (m.delete(`${src}|${frame}`)) emit();
21
+ },
22
+ clearAll(src = "cli") {
23
+ let changed = false;
24
+ for (const k of [...m.keys()]) if (k.startsWith(`${src}|`)) {
25
+ m.delete(k);
26
+ changed = true;
27
+ }
28
+ if (changed) emit();
29
+ },
30
+ active: frames,
31
+ sweep() {
32
+ const now = Date.now();
33
+ let changed = false;
34
+ for (const [k, until] of m) if (until <= now) {
35
+ m.delete(k);
36
+ changed = true;
37
+ }
38
+ if (changed) emit();
39
+ },
40
+ onChange(fn) {
41
+ cbs.push(fn);
42
+ return () => {
43
+ const i = cbs.indexOf(fn);
44
+ if (i !== -1) cbs.splice(i, 1);
45
+ };
46
+ }
47
+ };
48
+ }
49
+ //#endregion
50
+ //#region src/server/work.ts
51
+ /**
52
+ * The working-state rail for CHAT-driven agents (the jam daemon has its own in-process
53
+ * path into the same set). A coding agent that just accepted a request creates the frame
54
+ * files first, then `npx marver work start <frame...>` - the canvas shows the live
55
+ * working shimmer before a single component exists. `marver work done` clears it.
56
+ *
57
+ * Transport: the dev server writes design/.local/dev.json ({port, token}) at boot; the
58
+ * CLI reads it to find the server and authenticate. The token makes the endpoint
59
+ * unreachable from a drive-by browser page (which cannot read local files), while any
60
+ * process that can read the repo - the owner's own tools - is trusted by definition.
61
+ * Presence itself NEVER touches disk (activity.ts).
62
+ */
63
+ /** One per process - the dev server, the jam daemon, and the API all share it. */
64
+ const workActivity = createActivity();
65
+ /** The longest a CLI mark may glow unrefreshed - a forgotten `done` self-heals. */
66
+ const WORK_TTL_MAX = 18e5;
67
+ const WORK_TTL_DEFAULT = 6e5;
68
+ const infoPath = (root) => join(root, "design", ".local", "dev.json");
69
+ /** Written once per boot (dev.ts), removed on close - the CLI's discovery + credential. */
70
+ function writeDevInfo(root, port) {
71
+ const token = randomBytes(24).toString("base64url");
72
+ mkdirSync(join(root, "design", ".local"), { recursive: true });
73
+ writeFileSync(infoPath(root), JSON.stringify({
74
+ port,
75
+ token,
76
+ ts: Date.now()
77
+ }, null, 2) + "\n");
78
+ return token;
79
+ }
80
+ function removeDevInfo(root) {
81
+ try {
82
+ rmSync(infoPath(root), { force: true });
83
+ } catch {}
84
+ }
85
+ function readDevInfo(root) {
86
+ try {
87
+ const v = JSON.parse(readFileSync(infoPath(root), "utf8"));
88
+ return typeof v?.port === "number" && typeof v?.token === "string" ? {
89
+ port: v.port,
90
+ token: v.token
91
+ } : null;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+ //#endregion
97
+ export { WORK_TTL_DEFAULT, WORK_TTL_MAX, readDevInfo, removeDevInfo, workActivity, writeDevInfo };
@@ -0,0 +1,76 @@
1
+ import { n as NAME } from "./cli.mjs";
2
+ import { WORK_TTL_DEFAULT, WORK_TTL_MAX, readDevInfo } from "./work-CLrmY-vQ.mjs";
3
+ //#region src/cli/work.ts
4
+ /**
5
+ * `marver work` - the coding agent's hand on the canvas working state.
6
+ *
7
+ * work start <scene/frame ...> [--ttl <minutes>] mark frames actively working
8
+ * work done <scene/frame ...> | --all clear the glow
9
+ * work list what is glowing right now
10
+ *
11
+ * The intended choreography (taught in design/AGENTS.md): on accepting a request,
12
+ * create the frame FILES first (name, meta, a minimal skeleton), pin them on the
13
+ * right board, `work start` them - the human sees the request land on the canvas
14
+ * within seconds - build (independent frames in parallel, one subagent each), then
15
+ * `work done`. Marks self-expire (default 10 min, max 30) so a crashed agent can
16
+ * never leave a frame glowing forever.
17
+ */
18
+ async function workCommand(root, action, frames, opts) {
19
+ const info = readDevInfo(root);
20
+ if (!info) throw new Error(`\`${NAME} dev\` is not running in this repo (design/.local/dev.json not found) - start it first.`);
21
+ const call = async (method, body) => {
22
+ let res;
23
+ try {
24
+ res = await fetch(`http://localhost:${info.port}/__mv/api/work`, {
25
+ method,
26
+ headers: {
27
+ "content-type": "application/json",
28
+ "x-mv-work": info.token
29
+ },
30
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
31
+ });
32
+ } catch {
33
+ throw new Error(`could not reach \`${NAME} dev\` on port ${info.port} - is it still running?`);
34
+ }
35
+ const data = await res.json().catch(() => ({}));
36
+ if (res.status === 403) throw new Error(`the dev server refused the token - it likely restarted; try again (design/.local/dev.json is re-read each run)`);
37
+ if (!res.ok) throw new Error(String(data?.error ?? `work ${action} failed (${res.status})`));
38
+ if (!Array.isArray(data?.frames)) throw new Error(`port ${info.port} did not answer like \`${NAME} dev\` - is it still running?`);
39
+ return data;
40
+ };
41
+ switch (action) {
42
+ case "start": {
43
+ if (!frames.length) throw new Error("name the frames: work start <scene/frame ...>");
44
+ const min = Number(opts.ttl);
45
+ const ttlMs = Number.isFinite(min) && min > 0 ? Math.min(Math.max(min * 6e4, 1e4), WORK_TTL_MAX) : WORK_TTL_DEFAULT;
46
+ const { frames: active } = await call("POST", {
47
+ frames,
48
+ on: true,
49
+ ttlMs
50
+ });
51
+ const pretty = ttlMs >= 6e4 ? `${Math.round(ttlMs / 6e4)} min` : `${Math.round(ttlMs / 1e3)} s`;
52
+ console.log(`working: ${active.join(", ")} (auto-expires in ${pretty} - re-run to extend, \`work done\` to clear)`);
53
+ return;
54
+ }
55
+ case "done": {
56
+ if (!frames.length && !opts.all) throw new Error("name the frames (or --all): work done <scene/frame ...>");
57
+ const { frames: active } = await call("POST", opts.all ? {
58
+ on: false,
59
+ all: true
60
+ } : {
61
+ frames,
62
+ on: false
63
+ });
64
+ console.log(active.length ? `still working: ${active.join(", ")}` : "nothing working - all clear");
65
+ return;
66
+ }
67
+ case "list": {
68
+ const { frames: active } = await call("GET");
69
+ console.log(active.length ? active.join("\n") : "nothing working");
70
+ return;
71
+ }
72
+ default: throw new Error(`unknown action "${action}" - use start · done · list`);
73
+ }
74
+ }
75
+ //#endregion
76
+ export { workCommand };
package/package.json CHANGED
@@ -1,10 +1,24 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.8.0",
4
- "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components. The tool ships no AI - your coding agent is the designer.",
3
+ "version": "0.9.0",
4
+ "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "license": "Apache-2.0",
8
+ "author": "Nic Touron (https://github.com/TNEP4)",
9
+ "keywords": [
10
+ "design",
11
+ "canvas",
12
+ "agent",
13
+ "prototyping",
14
+ "design-tool",
15
+ "react",
16
+ "vite",
17
+ "tailwind"
18
+ ],
19
+ "bugs": {
20
+ "url": "https://github.com/TNEP4/marver/issues"
21
+ },
8
22
  "bin": {
9
23
  "marver": "dist/cli.mjs"
10
24
  },
@@ -55,6 +69,7 @@
55
69
  "engines": {
56
70
  "node": ">=22.18.0"
57
71
  },
72
+ "packageManager": "pnpm@11.0.8",
58
73
  "homepage": "https://marver.design",
59
74
  "repository": {
60
75
  "type": "git",
@@ -4,7 +4,7 @@ export const NAME = 'marver'
4
4
  export const PKG = '@marver-design/marver' // registry identity; bin stays `marver`
5
5
  export const ROUTE = '/__mv'
6
6
 
7
- /** Content-frame natural widths (SPEC-026): Doc layout -> own-size width.
7
+ /** Content-frame natural widths: Doc layout -> own-size width.
8
8
  * Shared by the Doc primitive (measurement messages) and the server-side
9
9
  * manifest scan (defaultSize for content frames) - one source, no drift. */
10
10
  export const CONTENT_WIDTH: Record<string, number> = { document: 760, wide: 1280 }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The Diagram block - first-class Mermaid (SPEC-026).
2
+ * The Diagram block - first-class Mermaid.
3
3
  * Mermaid is marver's dependency behind a dynamic import: a workspace with no
4
4
  * Diagram loads no mermaid bytes. Source-level theme overrides (%%{init}%%,
5
5
  * yaml frontmatter) are stripped so the marver palette always holds; the