@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 +62 -0
- package/dist/{build-p3xmXU3b.mjs → build-BrCl9hJS.mjs} +5 -3
- package/dist/cli.mjs +3 -3
- package/dist/{dev-DZi1yRhn.mjs → dev-DdeU-Jst.mjs} +57 -8
- package/dist/{init-Ck8z-HiD.mjs → init-DsCUmlCW.mjs} +1 -1
- package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
- package/dist/{plugin-CtQqO5ZZ.mjs → plugin-BtSGAm2h.mjs} +55 -19
- package/package.json +2 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/index.tsx +6 -2
- package/src/client/content/md.ts +29 -0
- package/src/client/content/palette.ts +6 -0
- package/src/client/frame-host/bridge.js +40 -5
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +61 -13
- package/src/client/shell/Comments.tsx +5 -4
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +66 -29
- package/src/client/shell/canvas/FrameNode.tsx +98 -8
- package/src/client/shell/canvas/frame-registry.ts +18 -0
- package/src/client/shell/canvas/snapshots.ts +233 -0
- package/src/client/shell/cursor-arrow-dark.svg +1 -0
- package/src/client/shell/cursor-arrow.svg +1 -0
- package/src/client/shell/labels.ts +10 -0
- package/src/client/shell/perf.ts +92 -0
- package/src/client/shell/store.ts +98 -6
- package/src/client/shell/styles.css +37 -7
- package/src/client/stage/main.tsx +2 -0
- package/templates/instructions/reference/color.md +22 -1
- package/templates/instructions/shape.md +23 -16
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 {
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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 {
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
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:
|
|
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 :
|
|
94
|
-
console.log(`\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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
type: "module",
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
if (
|
|
449
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
package/src/client/content/md.ts
CHANGED
|
@@ -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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
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) => {
|