@marver-design/marver 0.4.0 → 0.6.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 (33) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/dist/{build-p3xmXU3b.mjs → build-BZaPa2DS.mjs} +11 -5
  3. package/dist/cli.mjs +3 -3
  4. package/dist/{dev-DZi1yRhn.mjs → dev-DaPQ9xA5.mjs} +57 -8
  5. package/dist/{init-Ck8z-HiD.mjs → init-DsCUmlCW.mjs} +1 -1
  6. package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
  7. package/dist/{plugin-CtQqO5ZZ.mjs → plugin-wMY9lNf3.mjs} +67 -20
  8. package/package.json +2 -1
  9. package/src/client/content/diagram.tsx +46 -2
  10. package/src/client/content/img-lod.ts +107 -0
  11. package/src/client/content/index.tsx +37 -9
  12. package/src/client/content/md.ts +29 -0
  13. package/src/client/content/palette.ts +6 -0
  14. package/src/client/frame-host/bridge.js +40 -5
  15. package/src/client/frame-host/serialize.ts +195 -0
  16. package/src/client/shell/App.tsx +75 -18
  17. package/src/client/shell/Comments.tsx +5 -4
  18. package/src/client/shell/Play.tsx +24 -0
  19. package/src/client/shell/canvas/Canvas.tsx +69 -30
  20. package/src/client/shell/canvas/FrameNode.tsx +104 -8
  21. package/src/client/shell/canvas/camera-broadcast.ts +44 -0
  22. package/src/client/shell/canvas/frame-registry.ts +18 -0
  23. package/src/client/shell/canvas/snapshots.ts +233 -0
  24. package/src/client/shell/cursor-arrow-dark.svg +1 -0
  25. package/src/client/shell/cursor-arrow.svg +1 -0
  26. package/src/client/shell/labels.ts +10 -0
  27. package/src/client/shell/perf.ts +92 -0
  28. package/src/client/shell/store.ts +130 -22
  29. package/src/client/shell/styles.css +37 -7
  30. package/src/client/stage/main.tsx +2 -0
  31. package/templates/instructions/boards.md +9 -3
  32. package/templates/instructions/reference/color.md +22 -1
  33. package/templates/instructions/shape.md +44 -35
package/CHANGELOG.md CHANGED
@@ -2,6 +2,113 @@
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.6.0 - 2026-08-17
6
+
7
+ Image-heavy boards, done right. A board full of high-resolution screenshots now zooms fast and stays
8
+ crisp, images render in FULL instead of cropped, content frames size themselves to their content, and
9
+ the switcher opens on a tight landing board instead of loading every frame at once.
10
+
11
+ ### Added
12
+
13
+ - **Client-side image level-of-detail (LOD).** A board of 150+ high-res screenshots used to jank hard
14
+ on zoom - the real cost was decoded memory, not file size (a 2708x1610 PNG is ~17MB decoded, so 174 of
15
+ them held ~3GB of bitmaps the browser resampled every frame). Each `Img` now decodes STRAIGHT to its
16
+ on-screen size via `createImageBitmap` and paints on a canvas; bitmaps freeze during a pan/zoom and
17
+ re-pick resolution only when the gesture settles (the tldraw pattern). Result on a 174-image board:
18
+ ~26MB decoded at overview vs ~3GB before (~100x less), lag-free zoom, crisp detail when you stop.
19
+ Falls back to a plain `img` where `createImageBitmap` is unavailable.
20
+ - **Board ranking and a fast landing board.** Boards carry an `"order"` field; the switcher ranks curated
21
+ boards by it and always sinks the auto `all-scenes` everything-board to the BOTTOM. A fresh open now
22
+ lands on the FIRST curated board - a tight, fast board and a good first impression - instead of
23
+ rendering every frame at once. Rank boards deliberately; the first is what people see first. `order`
24
+ survives the shell's autosaves.
25
+
26
+ ### Changed
27
+
28
+ - **Reference images show in FULL.** `Img` no longer cover-crops to a fixed height (that sliced the
29
+ sides off every screenshot). Each image fills its column at its natural aspect ratio - never cropped,
30
+ never letterboxed - so same-aspect screenshots line up on their own and the frame auto-heights to fit.
31
+ Size an image by how many share its `Row` (fewer = bigger), not by a fixed height; `h` is accepted for
32
+ back-compat but no longer constrains size. A clean inset hairline (grayscale, light + dark) sits on the
33
+ image's own edge, overriding a screenshot's ragged or baked-in border instead of framing it twice.
34
+ - **Content frames fit their content when you resize.** A manual WIDTH resize now keeps the HEIGHT auto:
35
+ the frame reflows and refits to show everything, instead of freezing at a stale height (only an explicit
36
+ device viewport locks it). The content-frame height cap was raised so a long reference doc renders in
37
+ full rather than clipping, and zoom now reaches 500% for inspecting screenshot detail.
38
+ - **Authoring doctrine updated to match.** The scaffolded instructions now teach sizing images by row
39
+ grouping instead of cropping, and ranking boards with `order` (first = landing, `all-scenes` is heavy
40
+ and auto-last).
41
+
42
+ ### Fixed
43
+
44
+ - **No jiggle on zoom.** An image's display aspect-ratio is pinned on first decode, so an LOD resolution
45
+ switch changes only the pixels, never the layout box - frames no longer drift as you zoom.
46
+ - **Fast zoom no longer stalls frames.** The LOD re-decode is debounced past the gesture and drops queued
47
+ work when a new gesture starts, so oscillating zoom-in/out never stacks decode waves and times frames
48
+ out to a ready-timeout.
49
+
50
+ ## 0.5.0 - 2026-08-15
51
+
52
+ The performance & fidelity release (SPEC-M5): the canvas stops jiggling. Moving around a board no
53
+ longer swaps between two documents on every pan/zoom - each passive frame renders as a lean DOM
54
+ snapshot that IS what you see, and the real live app takes over the moment you interact with it.
55
+
56
+ ### Added
57
+
58
+ - **Lean-primary rendering.** Every passive frame shows a **DOM snapshot** - a self-contained static
59
+ copy of the frame (real DOM + real CSS, zero JavaScript) served in a `sandbox="allow-same-origin"`
60
+ iframe. It reflows on resize with the browser's own layout engine and carries the app's exact colors
61
+ (no rasterisation), so panning, zooming, and device-sweeping a board is smooth and pixel-honest. The
62
+ full live app sits underneath and swaps in instantly when you focus a frame (double-click), or in
63
+ laser/comment mode. This replaces the earlier screenshot facade, which invented colors and jittered.
64
+ - **Publish parity.** The lean tier now works in published builds (`marver build` → `marver serve`),
65
+ not just dev - captured client-side from the bundled same-origin frames, no build-time renderer.
66
+ - **Faster first paint.** Leans capture bounded-parallel and viewport-first, so the frames you're
67
+ looking at appear first and a big board settles in seconds instead of tens of seconds.
68
+ - **Content-frame color families.** Tag a diagram node with a built-in family - `HQ:::blue`,
69
+ `Carriers:::orange`, `Drivers:::purple` (also `green red gray`) - and it gets a filled, on-brand
70
+ color with a legible border in both themes, no `classDef` boilerplate. The same six names work in
71
+ `Md` prose as `:blue[the shipper's world]`, so a sentence and the diagram beside it read as one
72
+ color language.
73
+ - **`Head :: gloss` diagram labels.** A node label written `Head :: gloss` renders the head bold on
74
+ top with the gloss lighter and smaller below - a box scans as label-then-detail, no run-on.
75
+ - **Authoring doctrine that ships with the tool.** The scaffolded instructions
76
+ (`instructions/shape.md`, `instructions/reference/color.md`) now teach an agent these conventions -
77
+ the `::` label hierarchy, the `:::family` / `:blue[…]` palette, and "pick one family per concept and
78
+ hold it" - so diagrams and highlighted prose come out consistent by default instead of hand-rolled
79
+ hex and one-off `classDef`s.
80
+
81
+ ### Changed
82
+
83
+ - **Sidebar header** shows the humanized repo name (`marver-pilot` → "Marver Pilot", ellipsed if
84
+ long); the logo links to marver.design.
85
+ - **Sidebar board/scene labels** are humanized - kebab filenames render Title Case (`tms-specs` →
86
+ "Tms Specs"), dropping the dashes, while an explicit `meta.title` is honored verbatim.
87
+ - **App cursor** is the marver arrowhead - tilted, rounded, small, soft-shadowed, and theme-adaptive
88
+ (black-on-light / white-on-dark); reverts to a normal pointer in interact/prototype and keeps the
89
+ pin/crosshair in comment/laser mode.
90
+ - **Copy-file-path shortcut** moved to `Shift+P` (was a mislabeled `C`).
91
+
92
+ ### Fixed
93
+
94
+ - The canvas "jiggle" - text shifting ~1-2px when you click or zoom a frame - is gone; there is no
95
+ longer a per-gesture document swap to shift it.
96
+ - Mermaid diagrams no longer pop in/out or flash the wrong theme during zoom (async render is awaited;
97
+ a diagram's baked colors re-capture on theme change; the cover's color-scheme is pinned to the
98
+ frame theme, not the viewer's OS).
99
+ - A frame you've scrolled, typed into, or themed re-captures faithfully; agent edits (HMR) drop the
100
+ stale snapshot and rebuild; slow data that lands shortly after load triggers one bounded re-capture
101
+ (data that changes much later shows live the moment you focus the frame, and the lean rebuilds when
102
+ you leave it).
103
+
104
+ ### Known limitations
105
+
106
+ - Memory targets typical authoring boards (~15-20 frames); dozens of heavy production apps need the
107
+ bounded-residency milestone. A frame the serializer can't render faithfully (canvas/video/open- or
108
+ script-created-closed shadow-DOM/nested-iframe/cross-origin-CSS/blocked-CSP/oversized) degrades to
109
+ live automatically. (One narrow edge: a declarative closed shadow root can't be detected and may
110
+ render stale - rare in practice.)
111
+
5
112
  ## 0.4.0 - 2026-08-14
6
113
 
7
114
  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-wMY9lNf3.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";
@@ -144,7 +144,11 @@ async function buildSite(root, boardsFlag, allBoardsFlag) {
144
144
  const manifest = scanFrames(root);
145
145
  const allBoards = readBoards(root);
146
146
  const rights = resolvePublish(root, allBoards, boardsFlag, allBoardsFlag);
147
- const publishedNames = Object.keys(rights);
147
+ const boardOrder = (n) => {
148
+ const o = allBoards[n]?.order;
149
+ return typeof o === "number" && Number.isFinite(o) ? o : Infinity;
150
+ };
151
+ const publishedNames = Object.keys(rights).sort((a, b) => a === "all-scenes" ? 1 : b === "all-scenes" ? -1 : boardOrder(a) - boardOrder(b) || a.localeCompare(b));
148
152
  const includeAll = publishedNames.includes("all-scenes");
149
153
  const boards = {};
150
154
  for (const n of publishedNames) if (allBoards[n]) boards[n] = allBoards[n];
@@ -164,7 +168,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag) {
164
168
  },
165
169
  boards,
166
170
  names: publishedNames,
167
- default: publishedNames[0],
171
+ default: publishedNames.find((n) => n !== "all-scenes") ?? publishedNames[0],
168
172
  rights
169
173
  };
170
174
  const registryFile = posix(join(clientDir, "frame-host", "registry.ts"));
@@ -250,7 +254,9 @@ async function buildSite(root, boardsFlag, allBoardsFlag) {
250
254
  for (const f of frames.filter((x) => x.kind === "html")) {
251
255
  const src = readFileSync(join(root, f.file), "utf8");
252
256
  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;
257
+ let html = src.includes("</head>") ? src.replace("</head>", `${inject}</head>`) : inject + src;
258
+ 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>`;
259
+ html = /<head[^>]*>/i.test(html) ? html.replace(/<head[^>]*>/i, (m) => m + shim) : shim + html;
254
260
  mkdirSync(dirname(join(outDir, f.file)), { recursive: true });
255
261
  writeFileSync(join(outDir, f.file), html);
256
262
  }
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-DaPQ9xA5.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-BZaPa2DS.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-wMY9lNf3.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
@@ -79,9 +79,15 @@ function apiMiddleware(root) {
79
79
  mkdirSync(boardsDir, { recursive: true });
80
80
  return json(res, 200, readdirSync(boardsDir).filter((f) => f.endsWith(".json") && !f.endsWith(".tmp")).map((f) => {
81
81
  const content = readFileSync(join(boardsDir, f), "utf8");
82
+ let order;
83
+ try {
84
+ const o = JSON.parse(content)?.order;
85
+ if (typeof o === "number" && Number.isFinite(o)) order = o;
86
+ } catch {}
82
87
  return {
83
88
  name: f.replace(/\.json$/, ""),
84
- sha256: hash(content)
89
+ sha256: hash(content),
90
+ order
85
91
  };
86
92
  }));
87
93
  }
@@ -110,6 +116,10 @@ function apiMiddleware(root) {
110
116
  } catch {
111
117
  return json(res, 400, { error: "malformed JSON" });
112
118
  }
119
+ if (body.mustExist && !existsSync(p)) return json(res, 409, {
120
+ error: "board no longer exists on disk",
121
+ gone: true
122
+ });
113
123
  const current = existsSync(p) ? readFileSync(p, "utf8") : "";
114
124
  if (current && body.baseHash !== hash(current)) {
115
125
  let disk = null;
@@ -123,6 +133,11 @@ function apiMiddleware(root) {
123
133
  });
124
134
  }
125
135
  mkdirSync(boardsDir, { recursive: true });
136
+ const incoming = body.board;
137
+ if (incoming && typeof incoming === "object" && incoming.order === void 0 && current) try {
138
+ const o = JSON.parse(current).order;
139
+ if (typeof o === "number" && Number.isFinite(o)) incoming.order = o;
140
+ } catch {}
126
141
  const next2 = JSON.stringify(body.board, null, 2) + "\n";
127
142
  atomicWrite(p, next2);
128
143
  return json(res, 200, { sha256: hash(next2) });
@@ -373,6 +388,20 @@ const VIRTUAL_CONFIG = "virtual:sh-config";
373
388
  const VIRTUAL_DATA = "virtual:sh-data";
374
389
  function marverPlugin(ctx) {
375
390
  const { root, clientDir, config } = ctx;
391
+ let manifest = scanFrames(root);
392
+ let devServer = null;
393
+ const bootId = String(process.hrtime.bigint());
394
+ let invRev = 0;
395
+ const pendingInv = /* @__PURE__ */ new Set();
396
+ const flushInv = debounce(() => {
397
+ if (!devServer || !pendingInv.size) return;
398
+ const frameIds = [...pendingInv];
399
+ pendingInv.clear();
400
+ devServer.ws.send("sh:frame-invalidated", {
401
+ frameIds,
402
+ revision: `${bootId}:${++invRev}`
403
+ });
404
+ }, 120);
376
405
  /** Theme resolution: design/theme.css wrapper > configured > detected > empty (spec §5.4). */
377
406
  const themeFile = () => {
378
407
  const wrapper = join(root, "design", "theme.css");
@@ -383,6 +412,7 @@ function marverPlugin(ctx) {
383
412
  };
384
413
  return {
385
414
  name: "marver",
415
+ enforce: "pre",
386
416
  resolveId(id) {
387
417
  if (id === VIRTUAL_THEME) return themeFile() ?? "\0virtual:sh-theme.css";
388
418
  if (id === VIRTUAL_CONFIG) return "\0virtual:sh-config";
@@ -407,7 +437,8 @@ function marverPlugin(ctx) {
407
437
  themes: config.themes,
408
438
  zoomSpeed: config.zoomSpeed,
409
439
  noTheme: themeFile() == null,
410
- setup: setupPending
440
+ setup: setupPending,
441
+ projectName: basename(root)
411
442
  })}`;
412
443
  }
413
444
  if (id === "\0virtual:sh-data") return "export default null";
@@ -422,19 +453,27 @@ function marverPlugin(ctx) {
422
453
  const bridge = "/@fs/" + join(clientDir, "frame-host", "bridge.js").split("\\").join("/") + "?html=1";
423
454
  return {
424
455
  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
456
+ tags: [
457
+ {
458
+ tag: "script",
459
+ 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)};})();`,
460
+ injectTo: "head-prepend"
435
461
  },
436
- injectTo: "head-prepend"
437
- }]
462
+ {
463
+ tag: "script",
464
+ attrs: { type: "module" },
465
+ children: `import '${VIRTUAL_THEME}'`,
466
+ injectTo: "head-prepend"
467
+ },
468
+ {
469
+ tag: "script",
470
+ attrs: {
471
+ type: "module",
472
+ src: bridge
473
+ },
474
+ injectTo: "head-prepend"
475
+ }
476
+ ]
438
477
  };
439
478
  }
440
479
  },
@@ -444,9 +483,16 @@ function marverPlugin(ctx) {
444
483
  * Edits to loaded frames keep normal HMR (modules present + file exists → pass through).
445
484
  */
446
485
  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 [];
486
+ const affected = affectedFrameIds(hctx.file, root, manifest);
487
+ if (affected === null) {
488
+ if ([join(root, "design", "scenes"), join(root, "design", "components")].some((w) => hctx.file.startsWith(w)) && !existsSync(hctx.file)) return [];
489
+ return;
490
+ }
491
+ if (affected.length) {
492
+ for (const id of affected) pendingInv.add(id);
493
+ flushInv();
494
+ }
495
+ return [];
450
496
  },
451
497
  configureServer(server) {
452
498
  server.middlewares.use((req, res, next) => {
@@ -473,8 +519,9 @@ function marverPlugin(ctx) {
473
519
  });
474
520
  server.middlewares.use(apiMiddleware(root));
475
521
  server.middlewares.use(routesMiddleware(server, clientDir));
522
+ devServer = server;
476
523
  const regen = debounce(() => {
477
- const manifest = scanFrames(root);
524
+ manifest = scanFrames(root);
478
525
  if (writeManifest(root, manifest)) server.ws.send("sh:manifest", manifest);
479
526
  }, 150);
480
527
  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.6.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}`)