@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.
- package/CHANGELOG.md +107 -0
- package/dist/{build-p3xmXU3b.mjs → build-BZaPa2DS.mjs} +11 -5
- package/dist/cli.mjs +3 -3
- package/dist/{dev-DZi1yRhn.mjs → dev-DaPQ9xA5.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-wMY9lNf3.mjs} +67 -20
- package/package.json +2 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/img-lod.ts +107 -0
- package/src/client/content/index.tsx +37 -9
- 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 +75 -18
- package/src/client/shell/Comments.tsx +5 -4
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +69 -30
- package/src/client/shell/canvas/FrameNode.tsx +104 -8
- package/src/client/shell/canvas/camera-broadcast.ts +44 -0
- 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 +130 -22
- package/src/client/shell/styles.css +37 -7
- package/src/client/stage/main.tsx +2 -0
- package/templates/instructions/boards.md +9 -3
- package/templates/instructions/reference/color.md +22 -1
- 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 {
|
|
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-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
|
|
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
|
-
|
|
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-
|
|
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-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-
|
|
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 {
|
|
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-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:
|
|
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
|
|
@@ -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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
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
|
-
|
|
448
|
-
if (
|
|
449
|
-
|
|
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
|
-
|
|
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.
|
|
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}`)
|