@marver-design/marver 0.17.0 → 0.19.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 (48) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +3 -1
  3. package/dist/bake-kaf5kGZ7.mjs +747 -0
  4. package/dist/{build-D_g53Bp2.mjs → build-7ed5H2vT.mjs} +215 -13
  5. package/dist/cli.mjs +19 -11
  6. package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
  7. package/dist/{daemon-Bbh_jmui.mjs → daemon-DbHvLQUL.mjs} +1 -1
  8. package/dist/{dev-LnIISva5.mjs → dev-D3mP2x27.mjs} +205 -10
  9. package/dist/{init-B7YhcN2o.mjs → init-BQYCS3EU.mjs} +2 -2
  10. package/dist/{manifest-CaslQIAO.mjs → manifest-B01PSyDc.mjs} +34 -7
  11. package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
  12. package/dist/{plugin-D2msH1cj.mjs → plugin-DI-7NAnx.mjs} +108 -29
  13. package/dist/{poster-BEjUcQP3.mjs → poster-DNh6N27C.mjs} +1 -1
  14. package/dist/publish-bakes-Dp-ZFk3d.mjs +216 -0
  15. package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
  16. package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
  17. package/dist/{shot-BzQ0PXKH.mjs → shot-BFEuYbaz.mjs} +1 -1
  18. package/dist/{shot-DlmTO8AF.mjs → shot-DMDvDbeP.mjs} +14 -7
  19. package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
  20. package/docs/publish.md +27 -5
  21. package/docs/sticky-notes.md +43 -0
  22. package/package.json +2 -1
  23. package/src/client/content/diagram.tsx +27 -1
  24. package/src/client/content/index.tsx +2 -2
  25. package/src/client/content/md.ts +48 -0
  26. package/src/client/frame-host/bridge.js +4 -9
  27. package/src/client/frame-host/main.tsx +20 -6
  28. package/src/client/shell/App.tsx +14 -63
  29. package/src/client/shell/Comments.tsx +77 -14
  30. package/src/client/shell/Play.tsx +6 -3
  31. package/src/client/shell/canvas/Canvas.tsx +12 -3
  32. package/src/client/shell/canvas/FrameNode.tsx +99 -89
  33. package/src/client/shell/canvas/Sticky.tsx +284 -0
  34. package/src/client/shell/canvas/admission.ts +70 -0
  35. package/src/client/shell/canvas/sleep.ts +194 -0
  36. package/src/client/shell/goto.ts +72 -0
  37. package/src/client/shell/notes.ts +151 -0
  38. package/src/client/shell/store.ts +36 -18
  39. package/src/client/shell/styles.css +92 -25
  40. package/src/client/shell/tidy.ts +20 -2
  41. package/src/shared/sleep-rule.ts +41 -0
  42. package/templates/AGENTS-embedded.md +15 -0
  43. package/templates/AGENTS-studio.md +15 -0
  44. package/templates/instructions/craft.md +9 -0
  45. package/templates/instructions/publish.md +62 -9
  46. package/templates/instructions/shape.md +62 -0
  47. package/src/client/frame-host/serialize.ts +0 -195
  48. package/src/client/shell/canvas/snapshots.ts +0 -233
@@ -0,0 +1,216 @@
1
+ import { planShot, t as findChrome } from "./shot-DMDvDbeP.mjs";
2
+ import { ASK_MAX, bakeBatch } from "./bake-kaf5kGZ7.mjs";
3
+ import { MIME } from "./serve-z5qtj_wJ.mjs";
4
+ import { copyFileSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { extname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { createServer } from "node:http";
7
+ //#region src/server/publish-bakes.ts
8
+ /**
9
+ * Textures at publish time (spec 16, published canvases).
10
+ *
11
+ * A published canvas is a static site with no compiler, so its glass stayed live and a shared hi-fi
12
+ * board flashed on pan and zoom as before 0.18.0. Everything the compiler needs is known when the
13
+ * site is built: the published boards, every node's frame and size on them, and the themes a
14
+ * visitor can flip to. So the build serves design/.dist to itself on a loopback port, compiles every
15
+ * (frame, theme, size) against the PUBLISHED document - the one visitors get, not the dev one - with
16
+ * the same certification as the dev server (bake.ts), ships the certified textures under
17
+ * design/.dist/__mv/bakes/<build>/ and writes one static index the shell reads instead of asking a
18
+ * server. Anything the compiler refuses is simply absent from the index: that frame sleeps with the
19
+ * pause alone, glass live, as today. No Chrome on the build machine: the note is printed and the
20
+ * site ships without textures.
21
+ */
22
+ /** The shell's plain key for an ask (sleep.ts keyOf): frame|theme|w|h. */
23
+ const indexKey = (a) => `${a.frame}|${a.theme}|${Math.round(a.w)}|${Math.round(a.h)}`;
24
+ /** Every (frame, theme, size) a visitor can rest on: each published board's nodes, sized the way
25
+ * the shell sizes them (the node's own size, else the frame's default), each theme; the published
26
+ * `all-scenes` board shows every frame at its default size. Sizes are rounded first, then held to
27
+ * the compiler's limits: an oversize node is skipped, never clamped to a document of another size. */
28
+ function publishedAsks(boards, themes, frames, viewports, allScenes = false) {
29
+ const byId = new Map(frames.map((f) => [f.id, f]));
30
+ const seen = /* @__PURE__ */ new Map();
31
+ const add = (frame, w, h) => {
32
+ w = Math.round(w);
33
+ h = Math.round(h);
34
+ if (!(w >= 120) || !(h >= 80) || w > ASK_MAX.side || h > ASK_MAX.side || w * h > ASK_MAX.area) return;
35
+ for (const theme of themes.length ? themes : ["light"]) {
36
+ const ask = {
37
+ frame,
38
+ theme,
39
+ w,
40
+ h
41
+ };
42
+ seen.set(indexKey(ask), ask);
43
+ }
44
+ };
45
+ const size = (f, n) => {
46
+ const nw = n && typeof n.w === "number" && n.w > 0 ? n.w : void 0, nh = n && typeof n.h === "number" && n.h > 0 ? n.h : void 0;
47
+ const p = planShot(f, viewports, {});
48
+ if (p.fullHeight && !nh) return null;
49
+ return {
50
+ w: nw ?? p.width,
51
+ h: nh ?? p.initialHeight
52
+ };
53
+ };
54
+ for (const b of Object.values(boards)) for (const n of b?.nodes ?? []) {
55
+ const f = typeof n?.frame === "string" ? byId.get(n.frame) : void 0;
56
+ const s = f && size(f, n);
57
+ if (s) add(f.id, s.w, s.h);
58
+ }
59
+ if (allScenes) for (const f of frames) {
60
+ const s = size(f);
61
+ if (s) add(f.id, s.w, s.h);
62
+ }
63
+ return [...seen.values()];
64
+ }
65
+ /** The index: only answers that certified at least one texture, and only their certified targets
66
+ * (a refused target's selector ships nothing); everything else is absent. */
67
+ function publishedIndex(gen, answers) {
68
+ const out = {
69
+ gen,
70
+ answers: {}
71
+ };
72
+ for (const a of answers) {
73
+ if (!a.ok) continue;
74
+ const targets = a.targets.filter((t) => t.verified && t.texture);
75
+ if (targets.length) out.answers[indexKey(a)] = {
76
+ ok: true,
77
+ targets
78
+ };
79
+ }
80
+ return out;
81
+ }
82
+ /** Serve `dir` on a loopback port the way `marver serve` does (extensionless = index.html). */
83
+ function serveDir(dir) {
84
+ const real = realpathSync(dir);
85
+ const server = createServer((req, res) => {
86
+ let path = "";
87
+ try {
88
+ path = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
89
+ } catch {
90
+ res.statusCode = 400;
91
+ return res.end();
92
+ }
93
+ if (path.endsWith("/")) path += "index.html";
94
+ let file = resolve(dir, path.slice(1));
95
+ try {
96
+ const r = realpathSync(file);
97
+ if (relative(real, r).startsWith("..") || isAbsolute(relative(real, r))) throw 0;
98
+ file = r;
99
+ } catch {
100
+ file = join(dir, "index.html");
101
+ }
102
+ if (!extname(file)) file = join(dir, "index.html");
103
+ try {
104
+ const c = readFileSync(file);
105
+ res.setHeader("content-type", MIME[extname(file)] ?? "application/octet-stream");
106
+ res.end(c);
107
+ } catch {
108
+ res.statusCode = 404;
109
+ res.end();
110
+ }
111
+ });
112
+ return new Promise((ok) => server.listen(0, "127.0.0.1", () => {
113
+ ok({
114
+ origin: `http://127.0.0.1:${server.address().port}`,
115
+ close: () => server.close()
116
+ });
117
+ }));
118
+ }
119
+ /** Compile the published boards' frames against the built site and ship the textures with it.
120
+ * Returns null when there is no Chrome to compile with. */
121
+ async function bakePublished(opts) {
122
+ const { root, outDir, gen, boards, themes, frames, viewports, allScenes, urlFor, log } = opts;
123
+ if (!findChrome()) return null;
124
+ const t0 = Date.now();
125
+ const asks = publishedAsks(boards, themes, frames, viewports, allScenes).filter((a) => urlFor(a.frame, a.theme));
126
+ const stats = {
127
+ asked: asks.length,
128
+ asleep: 0,
129
+ live: 0,
130
+ plain: 0,
131
+ bytes: 0,
132
+ ms: 0
133
+ };
134
+ if (!asks.length) return stats;
135
+ const cache = join(root, "design", ".local", "bakes", String(gen));
136
+ const site = await serveDir(outDir);
137
+ let answers;
138
+ try {
139
+ answers = await bakeBatch({
140
+ root,
141
+ gen,
142
+ asks,
143
+ urlBase: "/__mv/bakes",
144
+ urlFor: (a) => site.origin + urlFor(a.frame, a.theme)
145
+ });
146
+ const index = publishedIndex(gen, answers);
147
+ const to = join(outDir, "__mv", "bakes");
148
+ rmSync(to, {
149
+ recursive: true,
150
+ force: true
151
+ });
152
+ const grammar = new RegExp(`^/__mv/bakes/${gen}/[0-9a-f]{16}/\\d+\\.png$`);
153
+ const realCache = (() => {
154
+ try {
155
+ return realpathSync(cache);
156
+ } catch {
157
+ return null;
158
+ }
159
+ })();
160
+ for (const [key, a] of Object.entries(index.answers)) {
161
+ const files = [];
162
+ if (!(realCache !== null && a.targets.every((t) => {
163
+ if (!grammar.test(t.texture)) return false;
164
+ const from = join(root, "design", ".local", "bakes", t.texture.replace(/^\/__mv\/bakes\//, ""));
165
+ try {
166
+ if (!lstatSync(from).isFile()) return false;
167
+ if (!realpathSync(from).startsWith(realCache + "/")) return false;
168
+ } catch {
169
+ return false;
170
+ }
171
+ files.push([from, join(outDir, t.texture.slice(1))]);
172
+ return true;
173
+ }))) {
174
+ delete index.answers[key];
175
+ continue;
176
+ }
177
+ for (const [from, dest] of files) {
178
+ mkdirSync(join(dest, ".."), { recursive: true });
179
+ copyFileSync(from, dest);
180
+ stats.bytes += statSync(dest).size;
181
+ }
182
+ }
183
+ mkdirSync(join(to, String(gen)), { recursive: true });
184
+ writeFileSync(join(to, String(gen), "index.json"), JSON.stringify(index));
185
+ for (const a of answers) {
186
+ if (!a.ok) {
187
+ stats.live++;
188
+ log?.(` bake: ${a.frame} ${a.theme} ${a.w}x${a.h} - stays live: ${a.error}`);
189
+ continue;
190
+ }
191
+ if (!(a.effects ?? a.targets.length)) {
192
+ stats.plain++;
193
+ continue;
194
+ }
195
+ if (!a.targets.length) {
196
+ stats.live++;
197
+ log?.(` bake: ${a.frame} ${a.theme} ${a.w}x${a.h} - ${a.effects} effects, all nested, stay live`);
198
+ continue;
199
+ }
200
+ const shipped = index.answers[indexKey(a)]?.targets.length ?? 0;
201
+ if (shipped) stats.asleep++;
202
+ else stats.live++;
203
+ log?.(` bake: ${a.frame} ${a.theme} ${a.w}x${a.h} - ${a.targets.length} effects, ${a.targets.length - shipped} stay live, ${a.ms} ms`);
204
+ }
205
+ } finally {
206
+ site.close();
207
+ rmSync(cache, {
208
+ recursive: true,
209
+ force: true
210
+ });
211
+ }
212
+ stats.ms = Date.now() - t0;
213
+ return stats;
214
+ }
215
+ //#endregion
216
+ export { bakePublished };
@@ -1,4 +1,4 @@
1
- import { n as NAME } from "./cli.mjs";
1
+ import { r as NAME } from "./cli.mjs";
2
2
  import { t as secureSuffix } from "./secure-cookie-_K1Hsx8H.mjs";
3
3
  import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
4
4
  import { extname, isAbsolute, join, relative, resolve } from "node:path";
@@ -147,7 +147,7 @@ async function serve(root, portFlag) {
147
147
  console.error(`[${NAME}] MARVER_ID_ISSUER is set but MARVER_PUBLIC_ORIGIN is not.\n Every assertion is bound to this canvas's exact origin, and it cannot be\n inferred from request headers - a proxy can make any request look local.\n Set it to the origin people actually reach this canvas on:\n MARVER_PUBLIC_ORIGIN=https://canvas.example.com\n MARVER_PUBLIC_ORIGIN=http://localhost:${portFlag ?? (Number(process.env.PORT) || 4199)} (development)`);
148
148
  process.exit(1);
149
149
  }
150
- const { marverIdHandler } = await import("./marver-id-gate-D6By7XHj.mjs");
150
+ const { marverIdHandler } = await import("./marver-id-gate-B_idGdHm.mjs");
151
151
  const { dataDir } = await import("./comments-DZyobpxG.mjs").then((n) => n.n);
152
152
  const canvasName = humanName(meta.name);
153
153
  const { ceilingsFromRights } = await import("./auth-DdUKeVKt.mjs").then((n) => n.R);
@@ -659,4 +659,4 @@ ${collabOn ? `
659
659
  }
660
660
  const esc = (s) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
661
661
  //#endregion
662
- export { serve, poweredByUrl as t };
662
+ export { MIME, serve, poweredByUrl as t };
@@ -1,4 +1,4 @@
1
- import { n as NAME } from "./cli.mjs";
1
+ import { r as NAME } from "./cli.mjs";
2
2
  import { M as resolveAccess } from "./auth-DdUKeVKt.mjs";
3
3
  import { i as loadCollab } from "./sync-BZaCWqK-.mjs";
4
4
  //#region src/cli/share.ts
@@ -1,4 +1,4 @@
1
- import { n as NAME } from "./cli.mjs";
1
+ import { r as NAME } from "./cli.mjs";
2
2
  import { readDevInfo } from "./work-CLrmY-vQ.mjs";
3
3
  //#region src/cli/shot.ts
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { a as slideSize, i as ROUTE } from "./cli.mjs";
1
+ import { a as ROUTE, o as slideSize } from "./cli.mjs";
2
2
  import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { randomBytes } from "node:crypto";
@@ -36,12 +36,19 @@ function findChrome() {
36
36
  }
37
37
  /** Profile directories marver has ever created (the sweep in shot.ts looks for both). */
38
38
  const PROFILE_PREFIXES = ["mv-shot-", "mv-browser-"];
39
+ /** What a Linux container needs and a desktop must not get: Chrome refuses to run as root with its
40
+ * sandbox on (a build image runs as root), and a container's /dev/shm is too small for its tiles. */
41
+ function containerFlags(platform, uid) {
42
+ return platform === "linux" && uid === 0 ? ["--no-sandbox", "--disable-dev-shm-usage"] : [];
43
+ }
39
44
  const FLAGS = [
40
- "--headless=new",
45
+ ...process.env.MV_CHROME_HEADED ? [] : ["--headless=new"],
41
46
  "--hide-scrollbars",
42
47
  "--no-first-run",
43
48
  "--no-default-browser-check",
44
- "--disable-extensions"
49
+ "--disable-extensions",
50
+ ...containerFlags(process.platform, process.getuid?.()),
51
+ ...process.env.MV_CHROME_FLAGS?.split(" ").filter(Boolean) ?? []
45
52
  ];
46
53
  const HANDSHAKE_MS = 15e3;
47
54
  const MAX_MESSAGE = 1 << 30;
@@ -50,9 +57,9 @@ const MAX_MESSAGE = 1 << 30;
50
57
  * and concatenated ONCE per delimiter (a screenshot answer is many chunks; re-concatenating
51
58
  * the remainder per chunk would be quadratic). The size guard runs BEFORE a segment is held. */
52
59
  var Frames = class {
53
- max;
54
60
  chunks = [];
55
61
  held = 0;
62
+ max;
56
63
  constructor(max = MAX_MESSAGE) {
57
64
  this.max = max;
58
65
  }
@@ -330,8 +337,8 @@ async function shootFrame(opts) {
330
337
  };
331
338
  const plan = planShot(frame, viewports, size);
332
339
  if (frame.kind !== "html") try {
333
- const { scanAssetRefs } = await import("./build-D_g53Bp2.mjs");
334
- const { ensurePoster } = await import("./poster-BEjUcQP3.mjs");
340
+ const { scanAssetRefs } = await import("./build-7ed5H2vT.mjs");
341
+ const { ensurePoster } = await import("./poster-DNh6N27C.mjs");
335
342
  const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
336
343
  for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
337
344
  } catch {}
@@ -923,4 +930,4 @@ async function captureIn({ url, width, height, out, fullHeight = false, timeoutM
923
930
  }
924
931
  }
925
932
  //#endregion
926
- export { capture, resolveFrames, shootBatch, shootFrame, sweepGhosts, findChrome as t };
933
+ export { AREA, SURFACE, capture, planShot, pool, resolveFrames, shootBatch, shootFrame, shotConcurrency, sweepGhosts, findChrome as t, withBrowser };
@@ -1,4 +1,4 @@
1
- import { n as NAME } from "./cli.mjs";
1
+ import { r as NAME } from "./cli.mjs";
2
2
  import { WORK_TTL_DEFAULT, WORK_TTL_MAX, readDevInfo } from "./work-CLrmY-vQ.mjs";
3
3
  //#region src/cli/work.ts
4
4
  /**
package/docs/publish.md CHANGED
@@ -36,6 +36,20 @@ only around published boards (a folder with nothing published never reaches the
36
36
  and `title`s and `description`s ship only for published things - the project's, the
37
37
  published boards', their folders', scenes' and frames'.
38
38
 
39
+ **Glass at rest.** `marver build` compiles the textures a hi-fi frame rests under (the same
40
+ certified compile as the dev canvas, spec 16) against the site it just built - every published
41
+ node, at its size on its board, in every theme - and ships them with it under `__mv/bakes/`. A
42
+ visitor's browser reads one static index; the shell is the same. Whatever the compiler cannot
43
+ certify (glass inside glass, blend modes, a frame whose paint is not a function of its URL) rests
44
+ with its glass live, as before. The compile needs Chrome on the machine that builds: without one
45
+ the build says so and ships without textures; `--no-textures` (or `MARVER_NO_TEXTURES=1` in CI)
46
+ skips it on purpose. A visitor who resizes a frame to a device preset sees it live at that size
47
+ (no texture was compiled for it). The textures are certified as the build machine renders the
48
+ frame, so bundle the fonts your frames use (`@fontsource-*`, or files under `public/`): a font
49
+ that exists only on a designer's laptop renders differently in a build container, and the
50
+ visitor's browser then refuses those textures and rests the frame live. A republish mints new
51
+ textures; a tab already open keeps the old shell and rests its glass live until it reloads.
52
+
39
53
  ## Who can open your canvas
40
54
 
41
55
  Three choices, and the canvas is public until you make one.
@@ -246,10 +260,13 @@ containerised canvas reports as one campaign.
246
260
 
247
261
  ## Railway (the one-pager)
248
262
 
249
- 1. Push your repo to GitHub and create a Railway service from it.
250
- 2. Build command: `npm ci && npx marver build`
251
- 3. Start command: `npx marver serve` (Railway's `$PORT` is picked up automatically)
252
- 4. Variables: `MARVER_PASSWORD=<your password>`
263
+ 1. Push your repo to GitHub with the Dockerfile below at its root, and create a Railway
264
+ service from it (Railway detects the Dockerfile; a Nixpacks build has no browser and ships
265
+ the hi-fi frames with live glass).
266
+ 2. Start command: `npx marver serve` (Railway's `$PORT` is picked up automatically)
267
+ 3. Variables: `MARVER_PASSWORD=<your password>`, or the identity gate from the table above.
268
+ 4. Read the build log: `textures: N frame views asleep under certified glass ...` is the line
269
+ that says the glass compiled; `textures: none - no Chrome` means the image has no browser.
253
270
 
254
271
  Deploy. The repo itself is the deployable - nothing to export, nothing to sync.
255
272
 
@@ -257,14 +274,19 @@ Deploy. The repo itself is the deployable - nothing to export, nothing to sync.
257
274
 
258
275
  ```dockerfile
259
276
  FROM node:22-slim
277
+ RUN apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation \
278
+ && rm -rf /var/lib/apt/lists/* # the browser `marver build` compiles the glass textures with
260
279
  WORKDIR /app # set share.name in design/config.ts - the fallback name is this directory
261
280
  COPY . .
262
- RUN npm ci && npx marver build
281
+ RUN npm ci && npx marver build # as root in a container: marver adds Chrome's --no-sandbox itself
263
282
  ENV PORT=8080
264
283
  EXPOSE 8080
265
284
  CMD ["npx", "marver", "serve"]
266
285
  ```
267
286
 
287
+ Without a browser in the image the build still succeeds, says so, and ships every feature but the
288
+ textures; `npx marver build --no-textures` skips the compile on purpose.
289
+
268
290
  ## Cloudflare Pages + Access (email/domain allowlists)
269
291
 
270
292
  For teams that want per-email policies instead of one password: build in CI
@@ -0,0 +1,43 @@
1
+ # Sticky notes
2
+
3
+ A sticky note is the aside beside a frame: a yellow note left of it on the canvas, in dev and on
4
+ every published or shared canvas, that says what the screen cannot - what the frame is for, how
5
+ two variations differ, how a mechanism works, an open question. Reviewers read it, click its links,
6
+ and comment on its text exactly as they comment on a frame.
7
+
8
+ ## Writing one
9
+
10
+ One markdown file, nothing to declare:
11
+
12
+ | For | File |
13
+ |---|---|
14
+ | a frame `checkout/cart` (`cart.tsx`, `.jsx` or `.html`) | `design/scenes/checkout/cart.note.md` |
15
+ | a scene `checkout` | `design/scenes/checkout/_note.md` (beside the scene's first frame on the board) |
16
+ | a component frame | `design/components/<name>.note.md` |
17
+
18
+ Markdown with the Md block's rules: headings, lists, tables, emphasis, code; `[text](goto:scene/frame)`
19
+ links jump to a frame; `http(s)` links open a new tab; images come from `design/assets/`; raw HTML is
20
+ inert. A scene note is 380 wide, a frame note 260; when a frame has both, they stack in one column,
21
+ scene first. An edit lands on the canvas as you save, without reloading the frame.
22
+
23
+ ## Diagrams
24
+
25
+ A ```mermaid fence renders hand-sketched on the paper: rough boxes, hatched fills, handwriting
26
+ labels, one ink. Write plain mermaid - no `%%{init}%%`, theme, colours or `style` lines, no URLs or
27
+ images in the source. Every family works; flowchart, sequence, state, class, ER, pie and mindmap
28
+ read best at note width, while gantt, journey, timeline, quadrant and git graphs are wide by nature
29
+ and want few items. This look is the note's alone: the `Diagram` block in content frames keeps its
30
+ own theme.
31
+
32
+ ## Reading one
33
+
34
+ - Fold: the dog-ear at the note's corner folds the column to a tab; the tab brings it back.
35
+ - `N` hides every note on the canvas and shows them again.
36
+ - Both are per viewer, in the browser - never saved to the board, never shared.
37
+ - Comments: in comment mode (`C`) click any element of a note; the pin sits on the note, follows a
38
+ fold onto the tab, and the thread card opens beside the column.
39
+
40
+ ## What it is not
41
+
42
+ A note is an aside, a screen's worth of reading at most. Specs, flows and mood boards stay content
43
+ frames on the board.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.17.0",
3
+ "version": "0.19.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 - comment @marver and your own coding agent does the work. The tool ships no AI.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -53,6 +53,7 @@
53
53
  "marked": "^16.0.0",
54
54
  "mermaid": "^11.6.0",
55
55
  "react-zoom-pan-pinch": "^3.6.1",
56
+ "roughjs": "^4.6.6",
56
57
  "vite": "^8.0.0",
57
58
  "zustand": "^5.0.0"
58
59
  },
@@ -67,6 +67,32 @@ export function withFamilies(src: string): string {
67
67
  return `${src}\n${defs}`
68
68
  }
69
69
 
70
+ /** The zero-external-request boundary, BEFORE render: mermaid's image shapes fetch their URL
71
+ * during render(), so post-render SVG sanitizing alone is too late. Rejected: any URL shape
72
+ * (scheme + // or scheme + \\, protocol-relative //), and the `img:` shape data of the
73
+ * flowchart node syntax `A@{ img: ... }` - the one construct that loads a resource at all. */
74
+ export function guardDiagramSource(src: string): void {
75
+ // judge the DECODED text: mermaid resolves \uXXXX, \xXX and HTML entities in shape data and
76
+ // labels before it acts on them, so an escaped `//` or a quoted, escaped `"img"` key is the
77
+ // same request in the end. Directives and front matter are gone by now (cleanSource) - a
78
+ // themeCSS with url() never reaches the renderer either way, url( is refused here too.
79
+ const text = decodeEscapes(src)
80
+ if (/(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(text) || /[a-z][a-z0-9+.-]*:\\/i.test(text)) throw new Error('URLs are not allowed in diagram source - use local design/assets/ images in an Img block instead')
81
+ if (/url\s*\(/i.test(text) || /@import\b/i.test(text)) throw new Error('external resources are not allowed in diagram source')
82
+ if (/@\s*\{/.test(text) && /["'`]?\s*img\s*["'`]?\s*:/i.test(text)) throw new Error('image shapes are not allowed in diagram source')
83
+ if (/%%\s*\{/.test(text) || /^\s*---/.test(text)) throw new Error('directives are not allowed in diagram source')
84
+ }
85
+ /** \uXXXX, \u{...}, \xXX and numeric/named HTML entities -> the characters they stand for. */
86
+ export function decodeEscapes(src: string): string {
87
+ return src
88
+ .replace(/\\u\{([0-9a-f]{1,6})\}/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
89
+ .replace(/\\u([0-9a-f]{4})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
90
+ .replace(/\\x([0-9a-f]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)))
91
+ .replace(/&#x([0-9a-f]{1,6});/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
92
+ .replace(/&#(\d{1,7});/g, (_, d) => String.fromCodePoint(Number(d)))
93
+ .replace(/&sol;/gi, '/').replace(/&bsol;/gi, '\\').replace(/&colon;/gi, ':').replace(/&quot;/gi, '"').replace(/&apos;/gi, "'")
94
+ }
95
+
70
96
  /** Remove external URL references from rendered SVG (images, links, href attrs). */
71
97
  export function sanitizeSvg(svg: string): string {
72
98
  const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
@@ -100,7 +126,7 @@ export function Diagram({ title, children }: { title?: string; children?: ReactN
100
126
  // shapes fetch their URL during render(), so post-render SVG sanitizing alone
101
127
  // would be too late. Reject ANY URL shape - absolute (scheme://) and
102
128
  // protocol-relative (//host) alike; neither has a place in diagram source.
103
- if (/(?:\w+:)?\/\//.test(src)) throw new Error('URLs are not allowed in diagram source - use local design/assets/ images in an Img block instead')
129
+ guardDiagramSource(src)
104
130
  const mermaid = (await import('mermaid')).default
105
131
  if (!live || mySeq !== seq) return
106
132
  mermaid.initialize({
@@ -10,7 +10,7 @@
10
10
  import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
11
11
  import { useInSlide } from './slide.tsx'
12
12
  import { CONTENT_WIDTH } from '../const.ts'
13
- import { assetUrl, renderMarkdown, FAMILIES } from './md.ts'
13
+ import { assetUrl, renderMarkdown, sanitizeMarkdownHtml, FAMILIES } from './md.ts'
14
14
  import { lodSupported, registerLodImage } from './img-lod.ts'
15
15
 
16
16
  // D3: family color classes for inline Md (`:blue[...]`), theme-aware (frames carry .dark + [data-theme])
@@ -87,7 +87,7 @@ export function Space({ n = 1 }: { n?: number }) {
87
87
  export function Md({ children }: { children?: ReactNode }) {
88
88
  ensureStyles()
89
89
  const src = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : String(children ?? '')
90
- const html = useMemo(() => renderMarkdown(src), [src])
90
+ const html = useMemo(() => sanitizeMarkdownHtml(renderMarkdown(src)), [src])
91
91
  return <div className="mv-md" dangerouslySetInnerHTML={{ __html: html }} />
92
92
  }
93
93
 
@@ -7,6 +7,9 @@
7
7
  import { Marked } from 'marked'
8
8
 
9
9
  const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
10
+ /** Prose text: markup escaped, but an entity the author wrote (`&copy;`, `&#x41;`) keeps its
11
+ * meaning - marked's own rule; a raw `<` is still never a tag. */
12
+ const escapeText = (s: string) => s.replace(/&(?!#?\w+;)|[<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
10
13
 
11
14
  /** D3: named color families for inline Md - the SAME families the diagrams use, so prose and
12
15
  * the diagram beside it speak one color language. Theme pairs (light/dark). Bound to classes,
@@ -38,6 +41,12 @@ const marked = new Marked({
38
41
  renderer: {
39
42
  // raw HTML (block or inline) renders as its literal text - inert by construction
40
43
  html(token: any) { return escapeHtml(String(token.text ?? '')) },
44
+ // marked leaves the text INSIDE a raw block (<script>, <style>, <pre>, <textarea>) unescaped
45
+ // (`token.escaped`); here every text token is escaped - there is no raw block to serve
46
+ text(token: any) {
47
+ if (token.tokens) return this.parser.parseInline(token.tokens)
48
+ return escapeText(String(token.text ?? ''))
49
+ },
41
50
  link(token: any) {
42
51
  const href = String(token.href ?? '')
43
52
  const inner = this.parser.parseInline(token.tokens ?? [])
@@ -77,3 +86,42 @@ marked.use({
77
86
  export function renderMarkdown(src: string): string {
78
87
  return marked.parse(src, { async: false }) as string
79
88
  }
89
+
90
+ // ---- the second gate: an allowlist over the rendered DOM ----------------------------------
91
+ // renderMarkdown is built to emit only these; this pass makes that a property of the OUTPUT,
92
+ // not of every renderer branch - the shell realm (sticky notes) is more privileged than a
93
+ // frame, so the HTML it inserts is checked element by element, attribute by attribute.
94
+ const TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'strong', 'em', 'del', 's', 'a', 'img', 'hr', 'br', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'span', 'input', 'div', 'sup', 'sub'])
95
+ export const ATTR_POLICY: Record<string, (v: string) => boolean> = {
96
+ href: (v) => v === '#' || /^https?:\/\//i.test(v) || /^mailto:/i.test(v),
97
+ // a frame id: any path text without markup, quotes, spaces or a scheme (ids are unicode-free
98
+ // by convention but not by law - `checkout/café` is a real id)
99
+ 'data-goto': (v) => v.length > 0 && v.length <= 300 && !/[\s<>"'`:\\]/.test(v) && !v.split('/').some((seg) => seg === '..' || seg === ''),
100
+ target: (v) => v === '_blank',
101
+ rel: (v) => v === 'noopener noreferrer',
102
+ // exactly what assetUrl emits: inside design/assets, no traversal SEGMENT (`flow..png` is a name)
103
+ src: (v) => v.startsWith('/design/assets/') && !v.slice('/design/assets/'.length).split('/').some((seg) => seg === '..' || seg === '.' || seg === ''),
104
+ alt: () => true, title: () => true, loading: (v) => v === 'lazy',
105
+ class: (v) => v.split(' ').every((c) => /^(mv-|language-)[^\s"'<>]+$/.test(c)),
106
+ align: (v) => /^(left|center|right)$/.test(v),
107
+ type: (v) => v === 'checkbox', checked: () => true, disabled: () => true,
108
+ start: (v) => /^\d+$/.test(v), colspan: (v) => /^\d+$/.test(v), rowspan: (v) => /^\d+$/.test(v),
109
+ }
110
+ /** Rendered markdown -> the same HTML with every element and attribute outside the allowlist
111
+ * removed (an element goes with its subtree; an attribute alone). Browser only (DOMParser). */
112
+ export function sanitizeMarkdownHtml(html: string): string {
113
+ const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
114
+ const walk = (el: Element) => {
115
+ for (const child of [...el.children]) {
116
+ if (!TAGS.has(child.tagName.toLowerCase())) { child.remove(); continue }
117
+ for (const a of [...child.attributes]) {
118
+ const ok = ATTR_POLICY[a.name]
119
+ if (!ok || !ok(a.value)) child.removeAttribute(a.name)
120
+ }
121
+ if (child.tagName === 'INPUT') child.setAttribute('disabled', '')
122
+ walk(child)
123
+ }
124
+ }
125
+ walk(doc.body)
126
+ return doc.body.innerHTML
127
+ }
@@ -12,20 +12,15 @@ const isHtmlFrame = new URL(import.meta.url).searchParams.get('html') === '1'
12
12
  const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, location.origin) }
13
13
  const id = new URLSearchParams(location.search).get('id') ?? location.pathname
14
14
 
15
- // The shell serialises this frame's DOM (same origin) for the lean facade. Open shadow roots
16
- // are walkable, but a CLOSED root is invisible after the fact - flag it at creation so the serialiser
17
- // degrades the frame (keeps it live) instead of shipping a lean copy missing its shadow content.
18
- const _attachShadow = Element.prototype.attachShadow
19
- if (_attachShadow) Element.prototype.attachShadow = function (init) {
20
- if (init && init.mode === 'closed') window.__mvClosedShadow = true
21
- return _attachShadow.call(this, init)
22
- }
23
-
24
15
  // theme lands as BOTH signals: [data-theme] plus the `dark` class Tailwind/shadcn key on
25
16
  const setTheme = (theme) => {
26
17
  document.documentElement.dataset.theme = theme
27
18
  document.documentElement.classList.toggle('dark', theme === 'dark')
19
+ // the shell sleeps a frame only under the theme it has actually painted: report it, two frames later
20
+ requestAnimationFrame(() => requestAnimationFrame(() => post({ type: 'sh:theme-applied', id, theme })))
28
21
  }
22
+ // the theme this document booted with (the URL's) counts as applied
23
+ requestAnimationFrame(() => requestAnimationFrame(() => post({ type: 'sh:theme-applied', id, theme: new URLSearchParams(location.search).get('theme') ?? 'light' })))
29
24
 
30
25
  if (isHtmlFrame) {
31
26
  const theme = new URLSearchParams(location.search).get('theme')
@@ -3,7 +3,7 @@
3
3
  * Boot failures (theme, providers, layouts, the frame itself) render a plain-DOM error card
4
4
  * and post sh:error; an ErrorBoundary catches render-time throws the same way.
5
5
  */
6
- import { Component, createElement, type ReactNode } from 'react'
6
+ import { Component, createElement, useLayoutEffect, type ReactNode } from 'react'
7
7
  import { createRoot } from 'react-dom/client'
8
8
 
9
9
  import './bridge.js'
@@ -53,9 +53,18 @@ class Boundary extends Component<{ children: ReactNode }, { err: Error | null }>
53
53
  }
54
54
  }
55
55
 
56
+ /** Posts sh:ready once the scene has COMMITTED (render() only schedules; a layout effect on the
57
+ * outermost element runs after every child's, before the first paint of the tree). */
58
+ function Committed({ onCommit, children }: { onCommit: () => void; children?: ReactNode }) {
59
+ useLayoutEffect(onCommit, [])
60
+ return children
61
+ }
62
+
56
63
  async function boot() {
64
+ const phases: Record<string, number> = { boot: Math.round(performance.now()) } // ms since navigation: where a boot spends its time (research/hifi/bootscale.ts)
57
65
  try {
58
66
  await import('virtual:sh-theme' as string)
67
+ phases.theme = Math.round(performance.now())
59
68
 
60
69
  const fileKey = frameFile(id)
61
70
  // Honest copy: the id usually IS valid on disk - this document's frame registry is
@@ -63,6 +72,7 @@ async function boot() {
63
72
  if (!fileKey) return fail(`frame "${id}" is not in this canvas's registry yet - the file was likely just added or renamed. The canvas should recover on its own; if this card persists, reload it.`)
64
73
 
65
74
  const frameMod: any = await frames[fileKey]()
75
+ phases.scene = Math.round(performance.now())
66
76
  const Frame = frameMod.default
67
77
  // No typeof gate: memo()/forwardRef() components are objects, not functions.
68
78
  // React + the ErrorBoundary validate the element type better than we can.
@@ -73,17 +83,21 @@ async function boot() {
73
83
  if (providerKey) wrappers.push((await providers[providerKey]() as any).default)
74
84
  for (const lk of layoutChain(fileKey)) wrappers.push((await layouts[lk]() as any).default)
75
85
 
86
+ phases.wrappers = Math.round(performance.now())
76
87
  let tree: ReactNode = createElement(Frame)
77
88
  for (const W of wrappers.reverse()) if (W != null) tree = createElement(W, null, tree)
78
89
 
79
- createRoot(document.getElementById('root')!).render(createElement(Boundary, null, tree))
80
- // stamp the URL revision so the shell can drop a ready queued by a superseded document (one it
81
- // auto-renavigated past) - a WindowProxy survives navigation, so a stale ready could otherwise
82
- // mark a reloading frame ready. Mirrors the sh:measure generation guard.
83
- post({ type: 'sh:ready', id, gen: params.get('r') ?? '', meta: frameMod.meta && typeof frameMod.meta === 'object' ? frameMod.meta : undefined })
90
+ // sh:ready on the first COMMIT, stamped with the URL revision so the shell can drop a ready queued
91
+ // by a superseded document (one it auto-renavigated past) - a WindowProxy survives navigation, so
92
+ // a stale ready could otherwise mark a reloading frame ready. Mirrors the sh:measure generation guard.
93
+ const ready = () => { phases.commit = Math.round(performance.now()); post({ type: 'sh:ready', id, gen: params.get('r') ?? '', meta: frameMod.meta && typeof frameMod.meta === 'object' ? frameMod.meta : undefined, phases }) }
94
+ createRoot(document.getElementById('root')!).render(createElement(Boundary, null, createElement(Committed, { onCommit: ready }, tree)))
84
95
  } catch (err) {
85
96
  fail((err as Error).message)
86
97
  }
87
98
  }
88
99
 
89
100
  boot()
101
+ // Fast Refresh keeps this document alive across edits: tell the shell its source changed, so a
102
+ // sleeping frame wakes and compiles again (a texture describes a source revision).
103
+ if (import.meta.hot) import.meta.hot.on('vite:afterUpdate', () => post({ type: 'sh:hmr', id }))