@marver-design/marver 0.18.0 → 0.19.1

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 (32) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +2 -1
  3. package/dist/{bake-jr38C_pX.mjs → bake-kaf5kGZ7.mjs} +1 -1
  4. package/dist/{build-ER_7T6Cw.mjs → build-7ed5H2vT.mjs} +40 -7
  5. package/dist/cli.mjs +3 -3
  6. package/dist/{daemon-CKhg0zuT.mjs → daemon-DbHvLQUL.mjs} +1 -1
  7. package/dist/{dev-BvLbY98O.mjs → dev-D3mP2x27.mjs} +6 -5
  8. package/dist/{init-BWbHqVng.mjs → init-BQYCS3EU.mjs} +1 -1
  9. package/dist/{manifest-DAnEL8_a.mjs → manifest-B01PSyDc.mjs} +33 -6
  10. package/dist/{plugin-Cai5C1WV.mjs → plugin-DI-7NAnx.mjs} +9 -9
  11. package/dist/{poster-CIuz_PwH.mjs → poster-DNh6N27C.mjs} +1 -1
  12. package/dist/{publish-bakes-D0LhbQQ3.mjs → publish-bakes-Dp-ZFk3d.mjs} +2 -2
  13. package/dist/{shot-iicees2e.mjs → shot-DMDvDbeP.mjs} +2 -2
  14. package/docs/sticky-notes.md +49 -0
  15. package/package.json +2 -1
  16. package/src/client/content/diagram.tsx +27 -1
  17. package/src/client/content/index.tsx +2 -2
  18. package/src/client/content/md.ts +48 -0
  19. package/src/client/shell/App.tsx +7 -63
  20. package/src/client/shell/Comments.tsx +75 -12
  21. package/src/client/shell/Play.tsx +6 -3
  22. package/src/client/shell/canvas/Canvas.tsx +10 -1
  23. package/src/client/shell/canvas/FrameNode.tsx +17 -0
  24. package/src/client/shell/canvas/Sticky.tsx +284 -0
  25. package/src/client/shell/goto.ts +72 -0
  26. package/src/client/shell/notes.ts +181 -0
  27. package/src/client/shell/store.ts +52 -25
  28. package/src/client/shell/styles.css +83 -0
  29. package/src/client/shell/tidy.ts +20 -2
  30. package/templates/AGENTS-embedded.md +18 -0
  31. package/templates/AGENTS-studio.md +18 -0
  32. package/templates/instructions/shape.md +69 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
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.19.1 - 2026-09-08
6
+
7
+ ### Fixed
8
+
9
+ - **A note landing on a composed board makes its own room.** 0.19.0 reserved a note's width only
10
+ when tidy ran; a note file added to a board that already had saved positions stood on its left
11
+ neighbour until someone pressed `t`. A board with a recipe (or the auto board) now re-applies
12
+ its layout when a note has no room - as the file lands while the board is open, and at load
13
+ when it landed while the board was closed - and saves the result. Boards dragged by hand keep
14
+ their positions, as before. A deleted frame's card counts as an obstacle; a drag inside the
15
+ reflow's debounce is never overwritten. The agent instructions say it plainly: placement is
16
+ the canvas's job, write the file.
17
+
18
+ ## 0.19.0 - 2026-09-08
19
+
20
+ ### Added
21
+
22
+ - **Sticky notes.** A markdown file beside a frame (`cart.note.md` next to `cart.tsx`, `.jsx` or
23
+ `.html`) or a scene (`_note.md` in the scene directory) renders as a yellow note left of the
24
+ frame on the canvas - the scene's on the scene's first frame of the board, above the frame's
25
+ own. Markdown with the Md block's rules (`goto:` links jump to a frame, images from
26
+ `design/assets/`, raw HTML inert) and ` ```mermaid ` fences drawn hand-drawn. Comments pin on a
27
+ note's elements as on a frame's; a folded note parks its pins on its tab. Tidy keeps room for
28
+ notes. Every viewer folds a note at its corner and hides all with `N`; the choice stays in the
29
+ browser, never in the board file. Published and shared canvases carry the notes, their images
30
+ copied like any Md image. An edit to a note file updates the sticky in place; the frame is never
31
+ reloaded. Every diagram family wears the note's look - flowchart, sequence, state, class, ER,
32
+ pie, mindmap, timeline, gantt, journey, quadrant, git graph, block: hand-sketched boxes, one
33
+ yellow palette, handwriting labels (the Diagram block in content frames keeps its own theme).
34
+
35
+ ### Changed
36
+
37
+ - The manifest carries `note` on frames and scenes; the dev watcher regenerates it when a note
38
+ file changes.
39
+ - The agent contract (`design/AGENTS.md`, refreshed by `npx marver init`) and
40
+ `instructions/shape.md` teach sticky notes: what one is, when to write one, what it supports
41
+ (markdown, tables, `goto:` links, images, hand-drawn mermaid) and how it works.
42
+ - Markdown in content frames: text inside a raw `<script>`/`<style>` block is now escaped like
43
+ every other text, and the rendered HTML passes a tag and attribute allowlist before insertion.
44
+ Diagram source is judged after decoding escapes; image shapes and directives are refused.
45
+
5
46
  ## 0.18.0 - 2026-09-07
6
47
 
7
48
  ### Changed
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  Screens, prototypes, specs, and now slide decks - all real code, all on one canvas, all shareable with people who sign in as themselves.
10
10
 
11
- [marver.design](https://marver.design) · [Slides](docs/slides.md) · [Live Jam](docs/live-jam.md) · [Deploying a canvas](docs/publish.md) · [Sharing](docs/sharing.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Issues](https://github.com/TNEP4/marver/issues)
11
+ [marver.design](https://marver.design) · [Slides](docs/slides.md) · [Sticky notes](docs/sticky-notes.md) · [Live Jam](docs/live-jam.md) · [Deploying a canvas](docs/publish.md) · [Sharing](docs/sharing.md) · [Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Issues](https://github.com/TNEP4/marver/issues)
12
12
 
13
13
  ## Quickstart
14
14
 
@@ -45,6 +45,7 @@ Frames appear on the canvas the moment the files land. That's the loop.
45
45
  - **Prototype links.** `data-goto="scene/frame"` on any element links frames into a walkable prototype - across boards, too.
46
46
  - **Five ways to view a board.** The canvas (frames on a plane), the board (the same, tidy), **present** (`p`: a full-screen clickable walkthrough - `data-goto` navigates, arrows step, `[` / `]` cycle variants, laser, comments, theme and device pickers in the toolbar), **focus** (one frame as a document - the reading preset for specs), and **slides** (a deck). A published board names its landing view; a frame deep link opens straight into it.
47
47
  - **Content frames.** Specs, Mermaid diagrams, mood boards, and slides live on the same canvas as the screens - import `Doc`, `Md`, `Diagram`, `Img`, `Slide`, `Chart`, `Video` from `@marver-design/marver/content` and think a feature through before any pixels exist. Works in a repo with no app at all: idea first, design second.
48
+ - **Sticky notes.** A markdown file beside a frame (`cart.note.md`) or a scene (`_note.md`) becomes a yellow note left of the frame on the canvas - what it is for, how two variations differ, an open question. Markdown, `goto:` links to frames, hand-drawn Mermaid; readers comment on a note's text like on a frame's, fold it to its corner, hide them all with `n`. The layout makes room for it; published canvases carry them.
48
49
  - **Hi-fi at rest.** A frame at rest is its own live document, asleep: animations paused and every `backdrop-filter` element painted with a certified texture of its filtered backdrop (compiled by the dev server's headless Chrome), so a board of 30 glass screens pans like 30 statics and wakes pixel-identical on interact. Glass inside glass, blend modes and frames whose paint depends on random data or the clock stay live; `?awake=1` keeps every frame live for comparison.
49
50
  - **Charts and video in any frame.** `Chart` (Apache ECharts, SVG, still at rest) inherits the ink, typeface and accent of whatever frame it sits in - a Tailwind dashboard, a dark spec, a slide - sizes its type to the context and follows the layout on resize. `Video` is poster-first everywhere: click to play wherever the frame is live, `autoplay` for an ambient loop, `ratio` for vertical clips; omit the poster and marver renders one from the clip. A screen with a chart or a clip is still a screen.
50
51
  - **Copy as image.** Select a frame, press `i` - a 2x PNG of it lands on the clipboard, rendered by the same headless Chrome that serves `marver shot`; `⇧i` for 4x (a slide is 5120×2880). Paste into Slack, a doc, or a chat with your agent.
@@ -1,4 +1,4 @@
1
- import { AREA, SURFACE, pool, shotConcurrency, withBrowser } from "./shot-iicees2e.mjs";
1
+ import { AREA, SURFACE, pool, shotConcurrency, withBrowser } from "./shot-DMDvDbeP.mjs";
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
3
3
  import { join, sep } from "node:path";
4
4
  import { createHash } from "node:crypto";
@@ -1,13 +1,14 @@
1
1
  import { a as ROUTE, r as NAME } from "./cli.mjs";
2
2
  import { a as listBoardFiles, d as flatten, f as isBoardName, n as checkBoardsDir, s as readRegistry, t as boardFields, u as buildTree } from "./boards-BmxcT3Lc.mjs";
3
- import { l as detectHost, n as scanFrames, s as loadConfig } from "./manifest-DAnEL8_a.mjs";
4
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-Cai5C1WV.mjs";
3
+ import { c as loadConfig, r as scanFrames, u as detectHost } from "./manifest-B01PSyDc.mjs";
4
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DI-7NAnx.mjs";
5
5
  import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, join, sep } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { createHash, randomBytes } from "node:crypto";
9
9
  import { build } from "vite";
10
10
  import react from "@vitejs/plugin-react";
11
+ import { Marked } from "marked";
11
12
  //#region src/server/css-fix.ts
12
13
  /** Every `-webkit-backdrop-filter` declaration (the property itself, never a custom property that
13
14
  * happens to end in it) is mirrored by the standard one right after it, in order, importance and
@@ -193,7 +194,37 @@ function scanAssetRefs(src, moduleId) {
193
194
  }
194
195
  if (posterM) out.push(posterM[1]);
195
196
  }
196
- for (const tpl of templates) for (const m of tpl.matchAll(/!\[[^\]]*\]\(([^)\s"']+)\)/g)) out.push(m[1]);
197
+ for (const tpl of templates) out.push(...markdownImageRefs(tpl.slice(1, -1)));
198
+ return out;
199
+ }
200
+ /** Markdown images in prose, through the parser: inline and reference-style, titles and
201
+ * parentheses in paths handled, code never counted. Decoded the way the client's assetUrl
202
+ * decodes (one pass; a path still carrying `%` is refused there and skipped here). Md content
203
+ * inside frames and sticky notes alike. */
204
+ function markdownImageRefs(md) {
205
+ const out = [];
206
+ const walk = (tokens) => {
207
+ for (const t of tokens) {
208
+ if (t.type === "image" && typeof t.href === "string") {
209
+ let p = t.href.trim();
210
+ try {
211
+ p = decodeURIComponent(p);
212
+ } catch {
213
+ continue;
214
+ }
215
+ if (!p.includes("%")) out.push(p);
216
+ }
217
+ if (t.type !== "code" && t.type !== "codespan") {
218
+ if (Array.isArray(t.tokens)) walk(t.tokens);
219
+ if (Array.isArray(t.items)) walk(t.items);
220
+ if (Array.isArray(t.rows)) for (const row of t.rows) walk(row.map((c) => ({ tokens: c.tokens })));
221
+ if (Array.isArray(t.header)) walk(t.header.map((c) => ({ tokens: c.tokens })));
222
+ }
223
+ }
224
+ };
225
+ try {
226
+ walk(new Marked({ gfm: true }).lexer(md));
227
+ } catch {}
197
228
  return out;
198
229
  }
199
230
  /** Same shape the client's assetUrl accepts: relative, inside design/assets/, no tricks. */
@@ -335,12 +366,13 @@ function publishedManifest(manifest, pubFrames, publishedNames, strip) {
335
366
  ...manifest.project ? { project: manifest.project } : {},
336
367
  ...pubFolders.length ? { folders: pubFolders } : {},
337
368
  ...pubBoards.length ? { boards: pubBoards } : {},
338
- scenes: manifest.scenes.filter((s) => pubScenes.has(s.name)).map(({ name, title, description, brief }) => ({
369
+ scenes: manifest.scenes.filter((s) => pubScenes.has(s.name)).map(({ name, title, description, brief, note }) => ({
339
370
  name,
340
371
  frames: pubFrames.filter((f) => f.scene === name).length,
341
372
  ...title ? { title } : {},
342
373
  ...description ? { description } : {},
343
- ...brief && !strip ? { brief } : {}
374
+ ...brief && !strip ? { brief } : {},
375
+ ...note ? { note } : {}
344
376
  })),
345
377
  frames: pubFrames
346
378
  };
@@ -646,11 +678,12 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds, textures =
646
678
  const rel = file.startsWith(rootP + "/") ? file.slice(rootP.length + 1) : file;
647
679
  for (const r of scanAssetRefs(src, rel)) refs.add(r);
648
680
  }
681
+ for (const note of [...pubManifest.frames.map((f) => f.note), ...pubManifest.scenes.map((s) => s.note)]) if (note) for (const r of markdownImageRefs(note)) refs.add(r);
649
682
  let copiedAssets = 0;
650
683
  const realAssets = existsSync(assetsDir) ? realpathSync(assetsDir) : null;
651
684
  for (const r of refs) {
652
685
  if (!isLocalAssetRef(r) || !r.endsWith(".poster.png") || existsSync(join(assetsDir, r))) continue;
653
- const { ensurePoster } = await import("./poster-CIuz_PwH.mjs");
686
+ const { ensurePoster } = await import("./poster-DNh6N27C.mjs");
654
687
  const g = await ensurePoster(assetsDir, r.slice(0, -11));
655
688
  if (!g.ok) throw new Error(`design/assets/${r}: ${g.error}`);
656
689
  console.log(` poster: rendered design/assets/${r} (${g.width}×${g.height})`);
@@ -717,7 +750,7 @@ async function buildSite(root, boardsFlag, allBoardsFlag, embedSeeds, textures =
717
750
  }));
718
751
  let textureLine = " textures: skipped (--no-textures)";
719
752
  if (textures && bakeGen) {
720
- const { bakePublished } = await import("./publish-bakes-D0LhbQQ3.mjs");
753
+ const { bakePublished } = await import("./publish-bakes-Dp-ZFk3d.mjs");
721
754
  const pubFrameFile = new Map(pubFrames.map((f) => [f.id, f]));
722
755
  const r = await bakePublished({
723
756
  root,
package/dist/cli.mjs CHANGED
@@ -72,14 +72,14 @@ const resolve$1 = (dir) => {
72
72
  };
73
73
  const cli = cac(NAME);
74
74
  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) => {
75
- const { init } = await import("./init-BWbHqVng.mjs");
75
+ const { init } = await import("./init-BQYCS3EU.mjs");
76
76
  init(resolve$1(opts.root), {
77
77
  mode: opts.mode === "embedded" ? "embedded" : "studio",
78
78
  demo: opts.demo !== false
79
79
  });
80
80
  });
81
81
  for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot reload, comments, Live Jam)"], ["canvas", "Start the local canvas - same as dev"]]) cli.command(name, desc).option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
82
- const { dev } = await import("./dev-BvLbY98O.mjs");
82
+ const { dev } = await import("./dev-D3mP2x27.mjs");
83
83
  let port;
84
84
  if (opts.port !== void 0) {
85
85
  const n = Number(opts.port);
@@ -89,7 +89,7 @@ for (const [name, desc] of [["dev", "Start the local canvas (everything on: hot
89
89
  await dev(resolve$1(opts.root), port);
90
90
  });
91
91
  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("--embed-seeds", "Copy comment history INTO the web root (identifying - every event carries its author's email)").option("--no-textures", "Skip compiling the glass textures the published frames rest under (needs Chrome; a CI without one skips on its own)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
92
- const { buildSite } = await import("./build-ER_7T6Cw.mjs");
92
+ const { buildSite } = await import("./build-7ed5H2vT.mjs");
93
93
  try {
94
94
  const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
95
95
  await buildSite(resolve$1(opts.root), boards, opts.allBoards === true, opts.embedSeeds === true, opts.textures !== false && !process.env.MARVER_NO_TEXTURES);
@@ -1,7 +1,7 @@
1
1
  import { n as replay } from "./events-B3LBn74P.mjs";
2
2
  import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
3
3
  import { n as localProfile } from "./profile-BjAPAJSb.mjs";
4
- import { i as toFrameId } from "./manifest-DAnEL8_a.mjs";
4
+ import { a as toFrameId } from "./manifest-B01PSyDc.mjs";
5
5
  import { workActivity } from "./work-CLrmY-vQ.mjs";
6
6
  import { r as deviceId, t as has } from "./ledger-Bu0BjqIe.mjs";
7
7
  import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
@@ -1,6 +1,6 @@
1
1
  import { i as PKG, r as NAME } from "./cli.mjs";
2
- import { l as detectHost, s as loadConfig } from "./manifest-DAnEL8_a.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-Cai5C1WV.mjs";
2
+ import { c as loadConfig, u as detectHost } from "./manifest-B01PSyDc.mjs";
3
+ import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DI-7NAnx.mjs";
4
4
  import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -437,6 +437,7 @@ async function dev(root, portFlag) {
437
437
  "react/jsx-dev-runtime",
438
438
  `${PKG} > marked`,
439
439
  `${PKG} > mermaid`,
440
+ `${PKG} > roughjs`,
440
441
  `${PKG} > html-to-image`,
441
442
  ...iconModules(root)
442
443
  ],
@@ -451,12 +452,12 @@ async function dev(root, portFlag) {
451
452
  console.log(`\n ${NAME} · ${projectName} → http://localhost:${port}/\n`);
452
453
  setTimeout(async () => {
453
454
  try {
454
- await (await import("./shot-iicees2e.mjs")).sweepGhosts((m) => console.log(` ${m}`));
455
+ await (await import("./shot-DMDvDbeP.mjs")).sweepGhosts((m) => console.log(` ${m}`));
455
456
  } catch {}
456
457
  }, 0);
457
458
  try {
458
459
  const { staleManagedInstructions } = await import("./managed-HwHVNI3h.mjs");
459
- const { installedVersion } = await import("./plugin-Cai5C1WV.mjs").then((n) => n.i);
460
+ const { installedVersion } = await import("./plugin-DI-7NAnx.mjs").then((n) => n.i);
460
461
  const stale = staleManagedInstructions(root);
461
462
  if (stale.length) {
462
463
  const shown = stale.slice(0, 4).join(", ") + (stale.length > 4 ? `, +${stale.length - 4} more` : "");
@@ -494,7 +495,7 @@ async function dev(root, portFlag) {
494
495
  });
495
496
  }
496
497
  if (config.jam) {
497
- const { startJam } = await import("./daemon-CKhg0zuT.mjs");
498
+ const { startJam } = await import("./daemon-DbHvLQUL.mjs");
498
499
  const jam = startJam(root, config.jam, (m) => console.log(m), (board) => server.ws.send("sh:jam-comment", { board }));
499
500
  if (jam) {
500
501
  const close = server.close.bind(server);
@@ -1,5 +1,5 @@
1
1
  import { r as NAME } from "./cli.mjs";
2
- import { a as writeManifest, c as detectAgent, l as detectHost, n as scanFrames, o as DEFAULTS, u as readJson } from "./manifest-DAnEL8_a.mjs";
2
+ import { d as readJson, l as detectAgent, o as writeManifest, r as scanFrames, s as DEFAULTS, u as detectHost } from "./manifest-B01PSyDc.mjs";
3
3
  import { MANAGED_PREFIX, enumerateInstructionTemplates, hashBody, managedFile, pkgDir } from "./managed-HwHVNI3h.mjs";
4
4
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, relative } from "node:path";
@@ -375,6 +375,23 @@ function validViewports(v) {
375
375
  //#region src/server/manifest.ts
376
376
  const FRAME_EXT = /\.(tsx|jsx|html)$/;
377
377
  const RESERVED_SCENES = /* @__PURE__ */ new Set(["components", "screens"]);
378
+ /** Sticky notes (spec 18): the longest note the manifest carries - a note is an aside, not a spec. */
379
+ const NOTE_MAX = 2e4;
380
+ /** The note file beside a frame file: `cart.tsx` -> `cart.note.md`; a scene's is `_note.md`. */
381
+ const noteFileFor = (frameFile) => frameFile.replace(FRAME_EXT, ".note.md");
382
+ /** Is this path a note file - the dev watcher's "regenerate on change" predicate. */
383
+ const isNoteFile = (file) => /(^|[\\/])(_note|[^\\/]+\.note)\.md$/.test(file);
384
+ /** A note's text: BOM stripped, trimmed, capped at NOTE_MAX. Undefined when the file is missing,
385
+ * empty, or not a regular file (a symlink is never followed - the `_brief.md` rule). */
386
+ function readNote(abs) {
387
+ try {
388
+ if (!lstatSync(abs).isFile()) return void 0;
389
+ const text = readFileSync(abs, "utf8").replace(/^/, "").trim();
390
+ return text ? text.slice(0, NOTE_MAX) : void 0;
391
+ } catch {
392
+ return;
393
+ }
394
+ }
378
395
  /** Extract `export const meta = {...}` with literal string values only. Anything else is silently omitted. */
379
396
  function extractMeta(src) {
380
397
  const m = /export\s+const\s+meta\s*=\s*\{([\s\S]*?)\}/.exec(src);
@@ -526,6 +543,10 @@ function sceneBrief(root, scene) {
526
543
  ...description ? { description } : {}
527
544
  };
528
545
  }
546
+ /** A scene's sticky note: `design/scenes/<scene>/_note.md` (spec 18). */
547
+ function sceneNote(root, scene) {
548
+ return scene ? readNote(join(root, "design", "scenes", scene, "_note.md")) : void 0;
549
+ }
529
550
  /** Write a scene's title into its brief's front matter - only that line changes; the body,
530
551
  * every other field and the file's own line endings are kept. No brief yet = a front-matter-only
531
552
  * brief. An empty title removes the line, and the file when nothing else is in it. The write
@@ -649,6 +670,8 @@ function scanFrames(root, project) {
649
670
  kind,
650
671
  scene
651
672
  };
673
+ const note = readNote(noteFileFor(abs));
674
+ if (note) entry.note = note;
652
675
  if (kind === "tsx") {
653
676
  const src = readFileSync(abs, "utf8");
654
677
  const meta = extractMeta(src);
@@ -684,11 +707,15 @@ function scanFrames(root, project) {
684
707
  inferVariantGroups(frames);
685
708
  const sceneCounts = /* @__PURE__ */ new Map();
686
709
  for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
687
- const scenes = [...sceneCounts.entries()].map(([name, n]) => ({
688
- name,
689
- frames: n,
690
- ...sceneBrief(root, name)
691
- })).sort((a, b) => a.name.localeCompare(b.name));
710
+ const scenes = [...sceneCounts.entries()].map(([name, n]) => {
711
+ const note = sceneNote(root, name);
712
+ return {
713
+ name,
714
+ frames: n,
715
+ ...sceneBrief(root, name),
716
+ ...note ? { note } : {}
717
+ };
718
+ }).sort((a, b) => a.name.localeCompare(b.name));
692
719
  return {
693
720
  ...project && (project.name || project.description) ? { project: {
694
721
  ...project.name ? { name: project.name } : {},
@@ -779,4 +806,4 @@ function writeManifest(root, manifest) {
779
806
  return true;
780
807
  }
781
808
  //#endregion
782
- export { writeManifest as a, detectAgent as c, toFrameId as i, detectHost as l, scanFrames as n, DEFAULTS as o, setSceneTitle as r, loadConfig as s, affectedFrameIds as t, readJson as u };
809
+ export { toFrameId as a, loadConfig as c, readJson as d, setSceneTitle as i, detectAgent as l, isNoteFile as n, writeManifest as o, scanFrames as r, DEFAULTS as s, affectedFrameIds as t, detectHost as u };
@@ -2,7 +2,7 @@ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { a as ROUTE, i as PKG, r as NAME } from "./cli.mjs";
3
3
  import { a as listBoardFiles, c as BOARD_NAME, g as hash, h as validateWire, i as isRegularFile, l as FOLDERS_FILE, m as readTitle, n as checkBoardsDir, o as nodeExists, p as readDescription, s as readRegistry, t as boardFields } from "./boards-BmxcT3Lc.mjs";
4
4
  import { n as localProfile, t as isConnected } from "./profile-BjAPAJSb.mjs";
5
- import { a as writeManifest, n as scanFrames, r as setSceneTitle, s as loadConfig, t as affectedFrameIds } from "./manifest-DAnEL8_a.mjs";
5
+ import { c as loadConfig, i as setSceneTitle, n as isNoteFile, o as writeManifest, r as scanFrames, t as affectedFrameIds } from "./manifest-B01PSyDc.mjs";
6
6
  import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
7
7
  import { basename, dirname, join, resolve, sep } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
@@ -422,7 +422,7 @@ function apiMiddleware(root, opts = {}) {
422
422
  };
423
423
  const asPng = url.searchParams.get("format") === "png";
424
424
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
425
- const { shootFrame } = await import("./shot-iicees2e.mjs");
425
+ const { shootFrame } = await import("./shot-DMDvDbeP.mjs");
426
426
  const r = await shootFrame({
427
427
  root,
428
428
  viewports: opts.viewports ?? {},
@@ -472,7 +472,7 @@ function apiMiddleware(root, opts = {}) {
472
472
  if (typeof theme !== "string" || !/^[a-z0-9-]+$/i.test(theme)) return json(res, 400, { error: "invalid theme" });
473
473
  const scale = body.scale === void 0 ? void 0 : body.scale;
474
474
  if (scale !== void 0 && !(Number.isInteger(scale) && scale >= 1 && scale <= 4)) return json(res, 400, { error: "invalid scale" });
475
- const { resolveFrames, shootBatch } = await import("./shot-iicees2e.mjs");
475
+ const { resolveFrames, shootBatch } = await import("./shot-DMDvDbeP.mjs");
476
476
  const sel = resolveFrames(root, body);
477
477
  if (!sel.ok) return json(res, sel.status, { error: sel.error });
478
478
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
@@ -500,7 +500,7 @@ function apiMiddleware(root, opts = {}) {
500
500
  return json(res, 400, { error: "malformed JSON" });
501
501
  }
502
502
  if (!body || typeof body !== "object" || !Array.isArray(body.asks) || !body.asks.length || body.asks.length > 200) return json(res, 400, { error: "asks must list 1-200 frames" });
503
- const { bakeBatch, ASK_MAX } = await import("./bake-jr38C_pX.mjs");
503
+ const { bakeBatch, ASK_MAX } = await import("./bake-kaf5kGZ7.mjs");
504
504
  let manifest = {};
505
505
  try {
506
506
  manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
@@ -544,8 +544,8 @@ function apiMiddleware(root, opts = {}) {
544
544
  if (path === "poster" && req.method === "GET") {
545
545
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
546
546
  const src = url.searchParams.get("src") ?? "";
547
- const { ensurePoster, isLocalClip } = await import("./poster-CIuz_PwH.mjs");
548
- const { isLocalAssetRef } = await import("./build-ER_7T6Cw.mjs");
547
+ const { ensurePoster, isLocalClip } = await import("./poster-DNh6N27C.mjs");
548
+ const { isLocalAssetRef } = await import("./build-7ed5H2vT.mjs");
549
549
  if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
550
550
  const r = await ensurePoster(join(root, "design", "assets"), src);
551
551
  if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
@@ -1027,7 +1027,7 @@ function marverPlugin(ctx) {
1027
1027
  };
1028
1028
  const prune = () => setTimeout(async () => {
1029
1029
  try {
1030
- (await import("./bake-jr38C_pX.mjs")).pruneBakes(root, bakeGen);
1030
+ (await import("./bake-kaf5kGZ7.mjs")).pruneBakes(root, bakeGen);
1031
1031
  } catch {}
1032
1032
  }, 500);
1033
1033
  prune();
@@ -1046,7 +1046,7 @@ function marverPlugin(ctx) {
1046
1046
  rescanTheme();
1047
1047
  }
1048
1048
  });
1049
- server.watcher.on("change", (f) => inScope(f) && (/\.(tsx|jsx)$/.test(f) || f.endsWith("_brief.md")) && regen());
1049
+ server.watcher.on("change", (f) => inScope(f) && (/\.(tsx|jsx)$/.test(f) || f.endsWith("_brief.md") || isNoteFile(f)) && regen());
1050
1050
  const configFile = join(root, "design", "config.ts");
1051
1051
  server.watcher.on("change", (f) => {
1052
1052
  if (f !== configFile) return;
@@ -1166,7 +1166,7 @@ function marverPlugin(ctx) {
1166
1166
  });
1167
1167
  return;
1168
1168
  }
1169
- const { shootFrame, shootBatch, resolveFrames } = await import("./shot-iicees2e.mjs");
1169
+ const { shootFrame, shootBatch, resolveFrames } = await import("./shot-DMDvDbeP.mjs");
1170
1170
  if (ways[0] === "frame") {
1171
1171
  if (typeof spec.frame !== "string") {
1172
1172
  write({
@@ -1,4 +1,4 @@
1
- import { capture, t as findChrome } from "./shot-iicees2e.mjs";
1
+ import { capture, t as findChrome } from "./shot-DMDvDbeP.mjs";
2
2
  import { constants, copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
@@ -1,5 +1,5 @@
1
- import { planShot, t as findChrome } from "./shot-iicees2e.mjs";
2
- import { ASK_MAX, bakeBatch } from "./bake-jr38C_pX.mjs";
1
+ import { planShot, t as findChrome } from "./shot-DMDvDbeP.mjs";
2
+ import { ASK_MAX, bakeBatch } from "./bake-kaf5kGZ7.mjs";
3
3
  import { MIME } from "./serve-z5qtj_wJ.mjs";
4
4
  import { copyFileSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { extname, isAbsolute, join, relative, resolve } from "node:path";
@@ -337,8 +337,8 @@ async function shootFrame(opts) {
337
337
  };
338
338
  const plan = planShot(frame, viewports, size);
339
339
  if (frame.kind !== "html") try {
340
- const { scanAssetRefs } = await import("./build-ER_7T6Cw.mjs");
341
- const { ensurePoster } = await import("./poster-CIuz_PwH.mjs");
340
+ const { scanAssetRefs } = await import("./build-7ed5H2vT.mjs");
341
+ const { ensurePoster } = await import("./poster-DNh6N27C.mjs");
342
342
  const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
343
343
  for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
344
344
  } catch {}
@@ -0,0 +1,49 @@
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
+ The room is the layout's job. Every layout the shell composes (a board's `layout` recipe, the auto
24
+ board, tidy, device views) reserves the note's width in front of its frame, and a note landing on a
25
+ board already composed re-applies the recipe so the frames make way - whether the board is open at
26
+ the time or not. Nobody moves frames for a note. A board dragged by hand keeps its positions; `t`
27
+ makes the room there.
28
+
29
+ ## Diagrams
30
+
31
+ A ```mermaid fence renders hand-sketched on the paper: rough boxes, hatched fills, handwriting
32
+ labels, one ink. Write plain mermaid - no `%%{init}%%`, theme, colours or `style` lines, no URLs or
33
+ images in the source. Every family works; flowchart, sequence, state, class, ER, pie and mindmap
34
+ read best at note width, while gantt, journey, timeline, quadrant and git graphs are wide by nature
35
+ and want few items. This look is the note's alone: the `Diagram` block in content frames keeps its
36
+ own theme.
37
+
38
+ ## Reading one
39
+
40
+ - Fold: the dog-ear at the note's corner folds the column to a tab; the tab brings it back.
41
+ - `N` hides every note on the canvas and shows them again.
42
+ - Both are per viewer, in the browser - never saved to the board, never shared.
43
+ - Comments: in comment mode (`C`) click any element of a note; the pin sits on the note, follows a
44
+ fold onto the tab, and the thread card opens beside the column.
45
+
46
+ ## What it is not
47
+
48
+ A note is an aside, a screen's worth of reading at most. Specs, flows and mood boards stay content
49
+ frames on the board.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
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
+ }