@marver-design/marver 0.4.0 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,68 @@
2
2
 
3
3
  Notable changes to `@marver-design/marver`. Format follows [Keep a Changelog](https://keepachangelog.com); versions follow semver.
4
4
 
5
+ ## 0.5.0 - 2026-08-15
6
+
7
+ The performance & fidelity release (SPEC-M5): the canvas stops jiggling. Moving around a board no
8
+ longer swaps between two documents on every pan/zoom - each passive frame renders as a lean DOM
9
+ snapshot that IS what you see, and the real live app takes over the moment you interact with it.
10
+
11
+ ### Added
12
+
13
+ - **Lean-primary rendering.** Every passive frame shows a **DOM snapshot** - a self-contained static
14
+ copy of the frame (real DOM + real CSS, zero JavaScript) served in a `sandbox="allow-same-origin"`
15
+ iframe. It reflows on resize with the browser's own layout engine and carries the app's exact colors
16
+ (no rasterisation), so panning, zooming, and device-sweeping a board is smooth and pixel-honest. The
17
+ full live app sits underneath and swaps in instantly when you focus a frame (double-click), or in
18
+ laser/comment mode. This replaces the earlier screenshot facade, which invented colors and jittered.
19
+ - **Publish parity.** The lean tier now works in published builds (`marver build` → `marver serve`),
20
+ not just dev - captured client-side from the bundled same-origin frames, no build-time renderer.
21
+ - **Faster first paint.** Leans capture bounded-parallel and viewport-first, so the frames you're
22
+ looking at appear first and a big board settles in seconds instead of tens of seconds.
23
+ - **Content-frame color families.** Tag a diagram node with a built-in family - `HQ:::blue`,
24
+ `Carriers:::orange`, `Drivers:::purple` (also `green red gray`) - and it gets a filled, on-brand
25
+ color with a legible border in both themes, no `classDef` boilerplate. The same six names work in
26
+ `Md` prose as `:blue[the shipper's world]`, so a sentence and the diagram beside it read as one
27
+ color language.
28
+ - **`Head :: gloss` diagram labels.** A node label written `Head :: gloss` renders the head bold on
29
+ top with the gloss lighter and smaller below - a box scans as label-then-detail, no run-on.
30
+ - **Authoring doctrine that ships with the tool.** The scaffolded instructions
31
+ (`instructions/shape.md`, `instructions/reference/color.md`) now teach an agent these conventions -
32
+ the `::` label hierarchy, the `:::family` / `:blue[…]` palette, and "pick one family per concept and
33
+ hold it" - so diagrams and highlighted prose come out consistent by default instead of hand-rolled
34
+ hex and one-off `classDef`s.
35
+
36
+ ### Changed
37
+
38
+ - **Sidebar header** shows the humanized repo name (`marver-pilot` → "Marver Pilot", ellipsed if
39
+ long); the logo links to marver.design.
40
+ - **Sidebar board/scene labels** are humanized - kebab filenames render Title Case (`tms-specs` →
41
+ "Tms Specs"), dropping the dashes, while an explicit `meta.title` is honored verbatim.
42
+ - **App cursor** is the marver arrowhead - tilted, rounded, small, soft-shadowed, and theme-adaptive
43
+ (black-on-light / white-on-dark); reverts to a normal pointer in interact/prototype and keeps the
44
+ pin/crosshair in comment/laser mode.
45
+ - **Copy-file-path shortcut** moved to `Shift+P` (was a mislabeled `C`).
46
+
47
+ ### Fixed
48
+
49
+ - The canvas "jiggle" - text shifting ~1-2px when you click or zoom a frame - is gone; there is no
50
+ longer a per-gesture document swap to shift it.
51
+ - Mermaid diagrams no longer pop in/out or flash the wrong theme during zoom (async render is awaited;
52
+ a diagram's baked colors re-capture on theme change; the cover's color-scheme is pinned to the
53
+ frame theme, not the viewer's OS).
54
+ - A frame you've scrolled, typed into, or themed re-captures faithfully; agent edits (HMR) drop the
55
+ stale snapshot and rebuild; slow data that lands shortly after load triggers one bounded re-capture
56
+ (data that changes much later shows live the moment you focus the frame, and the lean rebuilds when
57
+ you leave it).
58
+
59
+ ### Known limitations
60
+
61
+ - Memory targets typical authoring boards (~15-20 frames); dozens of heavy production apps need the
62
+ bounded-residency milestone. A frame the serializer can't render faithfully (canvas/video/open- or
63
+ script-created-closed shadow-DOM/nested-iframe/cross-origin-CSS/blocked-CSP/oversized) degrades to
64
+ live automatically. (One narrow edge: a declarative closed shadow root can't be detected and may
65
+ render stale - rare in practice.)
66
+
5
67
  ## 0.4.0 - 2026-08-14
6
68
 
7
69
  The collaboration release (SPEC-M3): the canvas becomes a place where colleagues,
@@ -1,6 +1,6 @@
1
1
  import { i as ROUTE, n as NAME } from "./cli.mjs";
2
- import { a as loadConfig, n as scanFrames, o as detectHost } from "./manifest-DW-T52MM.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-CtQqO5ZZ.mjs";
2
+ import { o as loadConfig, r as scanFrames, s as detectHost } from "./manifest-C8FODq2S.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BtSGAm2h.mjs";
4
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, sep } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -250,7 +250,9 @@ async function buildSite(root, boardsFlag, allBoardsFlag) {
250
250
  for (const f of frames.filter((x) => x.kind === "html")) {
251
251
  const src = readFileSync(join(root, f.file), "utf8");
252
252
  const inject = `${frameCss}\n<script type="module" src="/assets/bridge.js?html=1"><\/script>\n`;
253
- const html = src.includes("</head>") ? src.replace("</head>", `${inject}</head>`) : inject + src;
253
+ let html = src.includes("</head>") ? src.replace("</head>", `${inject}</head>`) : inject + src;
254
+ const shim = `<script>(function(){var a=Element.prototype.attachShadow;if(a)Element.prototype.attachShadow=function(i){if(i&&i.mode==='closed')window.__mvClosedShadow=1;return a.call(this,i)};})();<\/script>`;
255
+ html = /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (m) => m + shim) : shim + html;
254
256
  mkdirSync(dirname(join(outDir, f.file)), { recursive: true });
255
257
  writeFileSync(join(outDir, f.file), html);
256
258
  }
package/dist/cli.mjs CHANGED
@@ -39,14 +39,14 @@ function version() {
39
39
  }
40
40
  const cli = cac(NAME);
41
41
  cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "studio | embedded", { default: "studio" }).option("--no-demo", "Skip the demo scene (the demo ships unless this flag is passed)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
42
- const { init } = await import("./init-Ck8z-HiD.mjs");
42
+ const { init } = await import("./init-DsCUmlCW.mjs");
43
43
  init(resolve(opts.root), {
44
44
  mode: opts.mode === "embedded" ? "embedded" : "studio",
45
45
  demo: opts.demo !== false
46
46
  });
47
47
  });
48
48
  cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
49
- const { dev } = await import("./dev-DZi1yRhn.mjs");
49
+ const { dev } = await import("./dev-DdeU-Jst.mjs");
50
50
  let port;
51
51
  if (opts.port !== void 0) {
52
52
  const n = Number(opts.port);
@@ -56,7 +56,7 @@ cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root",
56
56
  await dev(resolve(opts.root), port);
57
57
  });
58
58
  cli.command("build", "Static export → design/.dist (what ships comes from design/publish.json - publishing is default-closed)").option("--boards <names>", "Publish only these boards (comma-separated); overrides the publish policy").option("--all-boards", "Publish every board - the loud override for the default-closed policy").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
59
- const { buildSite } = await import("./build-p3xmXU3b.mjs");
59
+ const { buildSite } = await import("./build-BrCl9hJS.mjs");
60
60
  try {
61
61
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
62
62
  await buildSite(resolve(opts.root), boards, opts.allBoards === true);
@@ -1,20 +1,57 @@
1
1
  import { n as NAME, r as PKG } from "./cli.mjs";
2
- import { a as loadConfig, o as detectHost } from "./manifest-DW-T52MM.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-CtQqO5ZZ.mjs";
4
- import { dirname, join } from "node:path";
2
+ import { o as loadConfig, s as detectHost } from "./manifest-C8FODq2S.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-BtSGAm2h.mjs";
4
+ import { basename, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createLogger, createServer, searchForWorkspaceRoot } from "vite";
7
7
  import react from "@vitejs/plugin-react";
8
+ import { createServer as createServer$1 } from "node:net";
8
9
  //#region src/server/dev.ts
9
10
  /** packageDir = the installed marver package root (dist/cli.js lives one level down). */
10
11
  function packageDir() {
11
12
  return join(dirname(fileURLToPath(import.meta.url)), "..");
12
13
  }
14
+ const portFree = (p) => new Promise((res) => {
15
+ const s = createServer$1();
16
+ s.once("error", () => res(false));
17
+ s.once("listening", () => s.close(() => res(true)));
18
+ s.listen(p, "127.0.0.1");
19
+ });
20
+ /** C1: a deterministic per-project port in [5200,5399] derived from the root path. Two projects
21
+ * never collide, and the same project always lands on the same port across restarts. */
22
+ const projectPort = (root) => {
23
+ let h = 0;
24
+ for (let i = 0; i < root.length; i++) h = Math.imul(h, 31) + root.charCodeAt(i) | 0;
25
+ return 5200 + Math.abs(h) % 200;
26
+ };
27
+ /** Pick the port to serve on: the desired one if free, else a DETERMINISTIC per-project fallback -
28
+ * never a silent next-free drift, which made a bookmarked tab silently serve a DIFFERENT project. */
29
+ async function pickPort(root, desired) {
30
+ if (await portFree(desired)) return {
31
+ port: desired,
32
+ fellBack: false
33
+ };
34
+ let p = projectPort(root);
35
+ for (let i = 0; i < 200; i++) {
36
+ if (p !== desired && await portFree(p)) return {
37
+ port: p,
38
+ fellBack: true
39
+ };
40
+ p = 5200 + (p - 5200 + 1) % 200;
41
+ }
42
+ return {
43
+ port: desired,
44
+ fellBack: true
45
+ };
46
+ }
13
47
  async function dev(root, portFlag) {
14
48
  const config = await loadConfig(root);
15
49
  const host = detectHost(root);
16
50
  const pkgDir = packageDir();
17
51
  const clientDir = join(pkgDir, "src", "client");
52
+ const projectName = basename(root);
53
+ const desiredPort = portFlag ?? (config.port !== 5199 ? config.port : void 0) ?? projectPort(root);
54
+ const picked = await pickPort(root, desiredPort);
18
55
  const plugins = [react()];
19
56
  if (host.tailwind === 4) {
20
57
  const tw = await tailwind4Plugin(root);
@@ -46,7 +83,7 @@ async function dev(root, portFlag) {
46
83
  customLogger: logger,
47
84
  plugins,
48
85
  server: {
49
- port: portFlag ?? config.port,
86
+ port: picked.port,
50
87
  strictPort: false,
51
88
  fs: { allow: [.../* @__PURE__ */ new Set([
52
89
  root,
@@ -66,7 +103,17 @@ async function dev(root, portFlag) {
66
103
  "**/dist/**",
67
104
  "**/build/**",
68
105
  "**/out/**",
69
- "**/coverage/**"
106
+ "**/coverage/**",
107
+ "**/.gstack/**",
108
+ "**/.git/**",
109
+ "**/.playwright-mcp/**"
110
+ ] },
111
+ warmup: { clientFiles: [
112
+ join(clientDir, "frame-host", "main.tsx"),
113
+ join(clientDir, "frame-host", "bridge.js"),
114
+ join(clientDir, "content", "index.tsx"),
115
+ join(clientDir, "content", "diagram.tsx"),
116
+ join(clientDir, "content", "md.ts")
70
117
  ] }
71
118
  },
72
119
  resolve: {
@@ -82,7 +129,8 @@ async function dev(root, portFlag) {
82
129
  "react/jsx-runtime",
83
130
  "react/jsx-dev-runtime",
84
131
  `${PKG} > marked`,
85
- `${PKG} > mermaid`
132
+ `${PKG} > mermaid`,
133
+ `${PKG} > html-to-image`
86
134
  ],
87
135
  entries: [join(clientDir, "frame-host", "index.html"), "design/**/*.{tsx,jsx}"]
88
136
  },
@@ -90,8 +138,9 @@ async function dev(root, portFlag) {
90
138
  });
91
139
  await server.listen();
92
140
  const addr = server.httpServer?.address();
93
- const port = typeof addr === "object" && addr ? addr.port : config.port;
94
- console.log(`\n ${NAME} canvas → http://localhost:${port}/\n`);
141
+ const port = typeof addr === "object" && addr ? addr.port : picked.port;
142
+ if (picked.fellBack) console.log(`\n port ${desiredPort} is in use - serving "${projectName}" on ${port} instead`);
143
+ console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
95
144
  const { loadCollab, syncOnce } = await import("./sync-CkBk-tUk.mjs").then((n) => n.a);
96
145
  if (loadCollab(root)) console.log(` comments: syncing with the published canvas (design/.local/collab.json)\n`);
97
146
  let syncing = false;
@@ -1,5 +1,5 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
- import { i as DEFAULTS, n as scanFrames, o as detectHost, r as writeManifest, s as readJson } from "./manifest-DW-T52MM.mjs";
2
+ import { a as DEFAULTS, c as readJson, i as writeManifest, r as scanFrames, s as detectHost } from "./manifest-C8FODq2S.mjs";
3
3
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -247,6 +247,31 @@ function toFrameId(designRelPath) {
247
247
  const noExt = designRelPath.split(sep).join("/").replace(FRAME_EXT, "");
248
248
  return noExt.startsWith("scenes/") ? noExt.slice(7) : noExt;
249
249
  }
250
+ /**
251
+ * A7: which frames does a changed `design/**` file affect? Manifest + directory conventions,
252
+ * NOT a module-graph walk (deterministic, cheap, may over-reload but never misses a
253
+ * conventionally-affected frame). Returns:
254
+ * null -> uncontrolled: leave to default Vite HMR (src/** deps, theme.css, config, assets)
255
+ * [] -> controlled but affects no current frame (e.g. a layout in an empty dir)
256
+ * [ids] -> controlled: the shell drives a rev-stamped reload of these frames
257
+ */
258
+ function affectedFrameIds(absFile, root, manifest) {
259
+ const design = join(root, "design");
260
+ if (absFile !== design && !absFile.startsWith(design + sep)) return null;
261
+ const rel = relative(design, absFile).split(sep).join("/");
262
+ const name = rel.split("/").pop() ?? rel;
263
+ const designRel = `design/${rel}`;
264
+ const tsxFrames = manifest.frames.filter((f) => f.kind === "tsx");
265
+ if (name === "theme.css") return null;
266
+ if (rel === "providers.tsx" || rel === "providers.jsx") return tsxFrames.map((f) => f.id);
267
+ if (/(^|\/)(_layout\.(tsx|jsx)|_fixtures\.(ts|tsx|js|jsx|json))$/.test(rel)) {
268
+ const prefix = `design/${rel.slice(0, rel.length - name.length)}`;
269
+ return tsxFrames.filter((f) => f.file.startsWith(prefix)).map((f) => f.id);
270
+ }
271
+ const direct = manifest.frames.find((f) => f.file === designRel);
272
+ if (direct) return [direct.id];
273
+ return null;
274
+ }
250
275
  function walk(dir, out = []) {
251
276
  if (!existsSync(dir)) return out;
252
277
  for (const e of readdirSync(dir, { withFileTypes: true })) {
@@ -399,4 +424,4 @@ function writeManifest(root, manifest) {
399
424
  }
400
425
  const hash = (s) => createHash("sha256").update(s).digest("hex");
401
426
  //#endregion
402
- export { loadConfig as a, DEFAULTS as i, scanFrames as n, detectHost as o, writeManifest as r, readJson as s, hash as t };
427
+ 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 };
@@ -1,7 +1,7 @@
1
1
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
2
- import { n as scanFrames, r as writeManifest, t as hash } from "./manifest-DW-T52MM.mjs";
2
+ import { i as writeManifest, n as hash, r as scanFrames, t as affectedFrameIds } from "./manifest-C8FODq2S.mjs";
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
4
- import { dirname, join, resolve, sep } from "node:path";
4
+ import { basename, dirname, join, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { randomBytes } from "node:crypto";
7
7
  //#region src/server/api.ts
@@ -110,6 +110,10 @@ function apiMiddleware(root) {
110
110
  } catch {
111
111
  return json(res, 400, { error: "malformed JSON" });
112
112
  }
113
+ if (body.mustExist && !existsSync(p)) return json(res, 409, {
114
+ error: "board no longer exists on disk",
115
+ gone: true
116
+ });
113
117
  const current = existsSync(p) ? readFileSync(p, "utf8") : "";
114
118
  if (current && body.baseHash !== hash(current)) {
115
119
  let disk = null;
@@ -373,6 +377,20 @@ const VIRTUAL_CONFIG = "virtual:sh-config";
373
377
  const VIRTUAL_DATA = "virtual:sh-data";
374
378
  function marverPlugin(ctx) {
375
379
  const { root, clientDir, config } = ctx;
380
+ let manifest = scanFrames(root);
381
+ let devServer = null;
382
+ const bootId = String(process.hrtime.bigint());
383
+ let invRev = 0;
384
+ const pendingInv = /* @__PURE__ */ new Set();
385
+ const flushInv = debounce(() => {
386
+ if (!devServer || !pendingInv.size) return;
387
+ const frameIds = [...pendingInv];
388
+ pendingInv.clear();
389
+ devServer.ws.send("sh:frame-invalidated", {
390
+ frameIds,
391
+ revision: `${bootId}:${++invRev}`
392
+ });
393
+ }, 120);
376
394
  /** Theme resolution: design/theme.css wrapper > configured > detected > empty (spec §5.4). */
377
395
  const themeFile = () => {
378
396
  const wrapper = join(root, "design", "theme.css");
@@ -383,6 +401,7 @@ function marverPlugin(ctx) {
383
401
  };
384
402
  return {
385
403
  name: "marver",
404
+ enforce: "pre",
386
405
  resolveId(id) {
387
406
  if (id === VIRTUAL_THEME) return themeFile() ?? "\0virtual:sh-theme.css";
388
407
  if (id === VIRTUAL_CONFIG) return "\0virtual:sh-config";
@@ -407,7 +426,8 @@ function marverPlugin(ctx) {
407
426
  themes: config.themes,
408
427
  zoomSpeed: config.zoomSpeed,
409
428
  noTheme: themeFile() == null,
410
- setup: setupPending
429
+ setup: setupPending,
430
+ projectName: basename(root)
411
431
  })}`;
412
432
  }
413
433
  if (id === "\0virtual:sh-data") return "export default null";
@@ -422,19 +442,27 @@ function marverPlugin(ctx) {
422
442
  const bridge = "/@fs/" + join(clientDir, "frame-host", "bridge.js").split("\\").join("/") + "?html=1";
423
443
  return {
424
444
  html,
425
- tags: [{
426
- tag: "script",
427
- attrs: { type: "module" },
428
- children: `import '${VIRTUAL_THEME}'`,
429
- injectTo: "head-prepend"
430
- }, {
431
- tag: "script",
432
- attrs: {
433
- type: "module",
434
- src: bridge
445
+ tags: [
446
+ {
447
+ tag: "script",
448
+ children: `(function(){var a=Element.prototype.attachShadow;if(a)Element.prototype.attachShadow=function(i){if(i&&i.mode==='closed')window.__mvClosedShadow=1;return a.call(this,i)};})();`,
449
+ injectTo: "head-prepend"
450
+ },
451
+ {
452
+ tag: "script",
453
+ attrs: { type: "module" },
454
+ children: `import '${VIRTUAL_THEME}'`,
455
+ injectTo: "head-prepend"
435
456
  },
436
- injectTo: "head-prepend"
437
- }]
457
+ {
458
+ tag: "script",
459
+ attrs: {
460
+ type: "module",
461
+ src: bridge
462
+ },
463
+ injectTo: "head-prepend"
464
+ }
465
+ ]
438
466
  };
439
467
  }
440
468
  },
@@ -444,9 +472,16 @@ function marverPlugin(ctx) {
444
472
  * Edits to loaded frames keep normal HMR (modules present + file exists → pass through).
445
473
  */
446
474
  handleHotUpdate(hctx) {
447
- if (![join(root, "design", "scenes"), join(root, "design", "components")].some((w) => hctx.file.startsWith(w))) return;
448
- if (!existsSync(hctx.file)) return [];
449
- if (hctx.modules.length === 0) return [];
475
+ const affected = affectedFrameIds(hctx.file, root, manifest);
476
+ if (affected === null) {
477
+ if ([join(root, "design", "scenes"), join(root, "design", "components")].some((w) => hctx.file.startsWith(w)) && !existsSync(hctx.file)) return [];
478
+ return;
479
+ }
480
+ if (affected.length) {
481
+ for (const id of affected) pendingInv.add(id);
482
+ flushInv();
483
+ }
484
+ return [];
450
485
  },
451
486
  configureServer(server) {
452
487
  server.middlewares.use((req, res, next) => {
@@ -473,8 +508,9 @@ function marverPlugin(ctx) {
473
508
  });
474
509
  server.middlewares.use(apiMiddleware(root));
475
510
  server.middlewares.use(routesMiddleware(server, clientDir));
511
+ devServer = server;
476
512
  const regen = debounce(() => {
477
- const manifest = scanFrames(root);
513
+ manifest = scanFrames(root);
478
514
  if (writeManifest(root, manifest)) server.ws.send("sh:manifest", manifest);
479
515
  }, 150);
480
516
  const watched = [join(root, "design", "scenes"), join(root, "design", "components")];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -33,6 +33,7 @@
33
33
  "@tailwindcss/vite": "^4.0.0",
34
34
  "@vitejs/plugin-react": "^6.0.0",
35
35
  "cac": "^7.0.0",
36
+ "html-to-image": "^1.11.13",
36
37
  "marked": "^16.0.0",
37
38
  "mermaid": "^11.6.0",
38
39
  "react-zoom-pan-pinch": "^3.6.1",
@@ -23,6 +23,50 @@ export function cleanSource(src: string): string {
23
23
  .trim()
24
24
  }
25
25
 
26
+ /** flowchart/graph is where classDef + markdown-string labels both apply; other diagram
27
+ * types (sequence, pie, ...) don't take the family/hierarchy sugar. */
28
+ function isFlowchart(src: string): boolean {
29
+ const first = src.split('\n').find((l) => l.trim())?.trim() ?? ''
30
+ return /^(flowchart|graph)\b/.test(first)
31
+ }
32
+
33
+ // D1: head/gloss auto-hierarchy. An agent writes a natural `Head :: gloss` label and marver
34
+ // renders the head BOLD on top with the gloss on a lighter, smaller line below - no backticks,
35
+ // no `**`, no `<br>` to hand-author. It expands the label into a mermaid markdown string (bold
36
+ // head, blank line -> a second <p> the THEME_CSS styles down). ` :: ` (spaced double colon) is
37
+ // the token: rare in prose, and distinct from the `:::family` class tag (no spaces, three colons).
38
+ const GLOSS = ' :: '
39
+ export function withLabelHierarchy(src: string): string {
40
+ if (!isFlowchart(src)) return src
41
+ return src.replace(/"([^"\n]*?)"/g, (m, body: string) => {
42
+ const at = body.indexOf(GLOSS)
43
+ if (at < 0 || body.startsWith('`')) return m // no token, or already a markdown string
44
+ const head = body.slice(0, at).trim()
45
+ const gloss = body.slice(at + GLOSS.length).trim()
46
+ if (!head || !gloss) return m
47
+ const bhead = /[*`]/.test(head) ? head : `**${head}**` // don't double-bold a hand-marked head
48
+ return `"\`${bhead}\n\n${gloss}\`"` // "`**Head**⏎⏎gloss`"
49
+ })
50
+ }
51
+
52
+ // D2: named family fills - the SAME colour language as the Md `:blue[...]` families, so an agent
53
+ // tags a node `HQ:::blue` with zero classDef boilerplate and prose + diagram read as one palette.
54
+ const DIAGRAM_FAMILIES: Record<string, string> = {
55
+ blue: 'fill:#0088FF,stroke:#0066CC,color:#fff',
56
+ orange: 'fill:#F5820A,stroke:#C96A08,color:#fff',
57
+ purple: 'fill:#B32BC8,stroke:#8F22A0,color:#fff',
58
+ green: 'fill:#1FA34A,stroke:#178139,color:#fff',
59
+ red: 'fill:#E5342B,stroke:#B71C13,color:#fff',
60
+ gray: 'fill:#E5E5EA,stroke:#C7C7CC,color:#1C1C1E',
61
+ }
62
+ /** Append the family classDefs to flowchart/graph diagrams (classDef is a flowchart feature).
63
+ * Unused defs are harmless; `X:::blue` resolves them regardless of position. */
64
+ export function withFamilies(src: string): string {
65
+ if (!isFlowchart(src)) return src
66
+ const defs = Object.entries(DIAGRAM_FAMILIES).map(([n, s]) => `classDef ${n} ${s}`).join('\n')
67
+ return `${src}\n${defs}`
68
+ }
69
+
26
70
  /** Remove external URL references from rendered SVG (images, links, href attrs). */
27
71
  export function sanitizeSvg(svg: string): string {
28
72
  const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
@@ -38,9 +82,9 @@ export function sanitizeSvg(svg: string): string {
38
82
  }
39
83
 
40
84
  export function Diagram({ title, children }: { title?: string; children?: ReactNode }) {
41
- const src = cleanSource(
85
+ const src = withFamilies(withLabelHierarchy(cleanSource(
42
86
  typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : String(children ?? ''),
43
- )
87
+ )))
44
88
  const ref = useRef<HTMLDivElement>(null)
45
89
  const [error, setError] = useState<string | null>(null)
46
90
  const uid = useRef(`mv-mmd-${++uidSeq}`)
@@ -9,7 +9,11 @@
9
9
  */
10
10
  import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
11
11
  import { CONTENT_WIDTH } from '../const.ts'
12
- import { assetUrl, renderMarkdown } from './md.ts'
12
+ import { assetUrl, renderMarkdown, FAMILIES } from './md.ts'
13
+
14
+ // D3: family color classes for inline Md (`:blue[...]`), theme-aware (frames carry .dark + [data-theme])
15
+ const FAMILY_CSS = Object.entries(FAMILIES).map(([f, c]) =>
16
+ `.mv-md .mv-c-${f}{color:${c.light}}.dark .mv-md .mv-c-${f},[data-theme="dark"] .mv-md .mv-c-${f}{color:${c.dark}}`).join('\n')
13
17
 
14
18
  export { Diagram } from './diagram.tsx'
15
19
 
@@ -106,7 +110,7 @@ function ensureStyles() {
106
110
  stylesIn = true
107
111
  const el = document.createElement('style')
108
112
  el.id = 'mv-content-css'
109
- el.textContent = CSS
113
+ el.textContent = CSS + '\n' + FAMILY_CSS
110
114
  document.head.appendChild(el)
111
115
  }
112
116
 
@@ -8,6 +8,19 @@ import { Marked } from 'marked'
8
8
 
9
9
  const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
10
10
 
11
+ /** D3: named color families for inline Md - the SAME families the diagrams use, so prose and
12
+ * the diagram beside it speak one color language. Theme pairs (light/dark). Bound to classes,
13
+ * never raw HTML (raw HTML stays inert). Used by the `:family[text]` extension + the frame CSS. */
14
+ export const FAMILIES: Record<string, { light: string; dark: string }> = {
15
+ blue: { light: '#0088FF', dark: '#3B9DFF' },
16
+ orange: { light: '#F5820A', dark: '#FF9F33' },
17
+ purple: { light: '#B32BC8', dark: '#D34FE8' },
18
+ green: { light: '#1FA34A', dark: '#34C759' },
19
+ red: { light: '#E5342B', dark: '#FF453A' },
20
+ gray: { light: '#8B95A3', dark: '#7D8794' },
21
+ }
22
+ const FAMILY_RE = new RegExp(`^:(${Object.keys(FAMILIES).join('|')})\\[([^\\]\\n]+)\\]`)
23
+
11
24
  /** Relative design/assets/ path -> served URL; null for anything else (fail closed).
12
25
  * Decodes BEFORE validating (a %2e%2e must not sneak past the ".." check) and
13
26
  * re-encodes per segment, so the validated path is the path the browser requests. */
@@ -45,6 +58,22 @@ const marked = new Marked({
45
58
  },
46
59
  })
47
60
 
61
+ // D3: `:blue[shipper's world]` -> a family-colored span (inline markdown inside still parses).
62
+ marked.use({
63
+ extensions: [{
64
+ name: 'mvcolor',
65
+ level: 'inline',
66
+ start(src: string) { return src.match(/:(?:blue|orange|purple|green|red|gray)\[/)?.index },
67
+ tokenizer(this: any, src: string) {
68
+ const m = FAMILY_RE.exec(src)
69
+ if (m) return { type: 'mvcolor', raw: m[0], family: m[1], tokens: this.lexer.inlineTokens(m[2]) }
70
+ },
71
+ renderer(this: any, token: any) {
72
+ return `<span class="mv-c-${token.family}">${this.parser.parseInline(token.tokens)}</span>`
73
+ },
74
+ }],
75
+ })
76
+
48
77
  export function renderMarkdown(src: string): string {
49
78
  return marked.parse(src, { async: false }) as string
50
79
  }
@@ -25,6 +25,12 @@ export const THEME_CSS = `
25
25
  .nodeLabel, .cluster-label { font-weight: 600; line-height: 1.4; }
26
26
  .nodeLabel p, .edgeLabel p, .label p { margin: 0; }
27
27
  .edgeLabel, .edgeLabel .label { font-weight: 500; }
28
+ /* D1 head/gloss hierarchy: a "Head :: gloss" label renders as two paragraphs -
29
+ bold head (the strong from the markdown), lighter/smaller gloss below. */
30
+ .nodeLabel p + p, .cluster-label p + p {
31
+ font-weight: 400; font-size: 0.84em; opacity: 0.68;
32
+ letter-spacing: 0; margin-top: 3px;
33
+ }
28
34
  `
29
35
 
30
36
  /** Mermaid themeVariables for one mode. Base theme + these = the marver look. */
@@ -4,6 +4,15 @@ const isHtmlFrame = new URL(import.meta.url).searchParams.get('html') === '1'
4
4
  const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
5
5
  const id = new URLSearchParams(location.search).get('id') ?? location.pathname
6
6
 
7
+ // SPEC-M5: the shell serialises this frame's DOM (same origin) for the lean facade. Open shadow roots
8
+ // are walkable, but a CLOSED root is invisible after the fact - flag it at creation so the serialiser
9
+ // degrades the frame (keeps it live) instead of shipping a lean copy missing its shadow content.
10
+ const _attachShadow = Element.prototype.attachShadow
11
+ if (_attachShadow) Element.prototype.attachShadow = function (init) {
12
+ if (init && init.mode === 'closed') window.__mvClosedShadow = true
13
+ return _attachShadow.call(this, init)
14
+ }
15
+
7
16
  // theme lands as BOTH signals: [data-theme] plus the `dark` class Tailwind/shadcn key on
8
17
  const setTheme = (theme) => {
9
18
  document.documentElement.dataset.theme = theme
@@ -32,6 +41,8 @@ window.addEventListener('error', (e) => post({ type: 'sh:error', id, message: St
32
41
  window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:error', id, message: `unhandled rejection: ${e.reason}` }))
33
42
 
34
43
  window.addEventListener('message', (e) => {
44
+ // commands come from the SHELL (parent) only - embedded app content must not flip the theme
45
+ if (e.source !== window.parent || window.parent === window) return
35
46
  if (e?.data?.type === 'sh:set-theme') setTheme(e.data.theme)
36
47
  })
37
48
 
@@ -40,10 +51,23 @@ if (isHtmlFrame) {
40
51
  document.readyState === 'loading' ? addEventListener('DOMContentLoaded', ready) : ready()
41
52
  }
42
53
 
43
- // pinch inside a frame must not zoom the parent PAGE (wheel events here belong to the
44
- // iframe's document, so the shell's blocker cannot see them). Keyboard cmd +/- untouched.
45
- window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) e.preventDefault() }, { passive: false })
54
+ // B0.2 wheel ownership. A frame is either the interact/play target (the APP owns wheel -
55
+ // its own scroll) or passive (laser/comment/plain view - the CANVAS owns wheel). Wheel
56
+ // events land in the iframe's document, so the shell can't see them: when passive we
57
+ // forward them to the shell, when interactive we leave them for the app (only blocking
58
+ // the browser's own ctrl/meta page pinch-zoom). preventing here is mandatory - the parent
59
+ // gets the forwarded message too late to cancel the iframe's own scroll.
60
+ let interactiveOn = false
61
+ window.addEventListener('wheel', (e) => {
62
+ if (interactiveOn) { if (e.ctrlKey || e.metaKey) e.preventDefault(); return }
63
+ e.preventDefault()
64
+ e.stopImmediatePropagation()
65
+ post({ type: 'sh:wheel', id, deltaX: e.deltaX, deltaY: e.deltaY, deltaMode: e.deltaMode,
66
+ ctrlKey: e.ctrlKey, metaKey: e.metaKey, clientX: e.clientX, clientY: e.clientY })
67
+ }, { capture: true, passive: false })
46
68
  document.addEventListener('gesturestart', (e) => e.preventDefault())
69
+ // a nested scroll container hitting its boundary must not chain into the shell page
70
+ document.documentElement.style.overscrollBehavior = 'contain'
47
71
 
48
72
  // ---- laser mode + element picking (SPEC-M3 §5, §7) ----------------------------------
49
73
  // Laser: one injected stylesheet, outline only (zero layout shift), depth-based hue -
@@ -91,6 +115,15 @@ const COPIED_HTML = `<svg width="12" height="12" viewBox="0 0 256 256" fill="cur
91
115
  let laserOn = false, pickOn = false, hoverEl = null, labelEl = null
92
116
  const modeActive = () => laserOn || pickOn
93
117
 
118
+ // A6: report transient laser/comment engagement (pointer inside this frame AND a mode on) so the
119
+ // shell leases the frame - a hot update to it then defers until the pointer leaves or the mode
120
+ // ends, instead of yanking the user mid-inspect/mid-comment.
121
+ let pointerInside = false
122
+ const reportInteraction = () => post({ type: 'sh:interaction', id, laser: pointerInside && laserOn, comment: pointerInside && pickOn })
123
+ document.addEventListener('mouseover', () => { if (!pointerInside) { pointerInside = true; reportInteraction() } })
124
+ document.addEventListener('mouseout', (e) => { if (!e.relatedTarget) { pointerInside = false; reportInteraction() } })
125
+ window.addEventListener('blur', () => { if (pointerInside) { pointerInside = false; reportInteraction() } })
126
+
94
127
  // laser and pick are independent looks over shared hover machinery: the stylesheet
95
128
  // is regenerated on every flip so each mode contributes exactly its own rules
96
129
  const applyModes = () => {
@@ -258,8 +291,10 @@ window.addEventListener('message', (e) => {
258
291
  const m = e?.data
259
292
  if (!m || typeof m !== 'object') return
260
293
  // independent toggles - each mode contributes its own rules, applyModes composes
261
- if (m.type === 'sh:laser') { laserOn = !!m.on; applyModes() }
262
- if (m.type === 'sh:pick') { pickOn = !!m.on; applyModes() }
294
+ if (m.type === 'sh:laser') { laserOn = !!m.on; applyModes(); reportInteraction() }
295
+ if (m.type === 'sh:pick') { pickOn = !!m.on; applyModes(); reportInteraction() }
296
+ // B0.2: interact/play target owns its own wheel; passive frames forward it to the canvas
297
+ if (m.type === 'sh:interactive') { interactiveOn = !!m.on }
263
298
  if (m.type === 'sh:copy-ok') showCopied(m.seq)
264
299
  if (m.type === 'sh:resolve-anchors' && Array.isArray(m.anchors)) {
265
300
  const rects = m.anchors.slice(0, 200).map((a) => {