@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.
- package/CHANGELOG.md +96 -0
- package/README.md +3 -1
- package/dist/bake-kaf5kGZ7.mjs +747 -0
- package/dist/{build-D_g53Bp2.mjs → build-7ed5H2vT.mjs} +215 -13
- package/dist/cli.mjs +19 -11
- package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
- package/dist/{daemon-Bbh_jmui.mjs → daemon-DbHvLQUL.mjs} +1 -1
- package/dist/{dev-LnIISva5.mjs → dev-D3mP2x27.mjs} +205 -10
- package/dist/{init-B7YhcN2o.mjs → init-BQYCS3EU.mjs} +2 -2
- package/dist/{manifest-CaslQIAO.mjs → manifest-B01PSyDc.mjs} +34 -7
- package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
- package/dist/{plugin-D2msH1cj.mjs → plugin-DI-7NAnx.mjs} +108 -29
- package/dist/{poster-BEjUcQP3.mjs → poster-DNh6N27C.mjs} +1 -1
- package/dist/publish-bakes-Dp-ZFk3d.mjs +216 -0
- package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
- package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
- package/dist/{shot-BzQ0PXKH.mjs → shot-BFEuYbaz.mjs} +1 -1
- package/dist/{shot-DlmTO8AF.mjs → shot-DMDvDbeP.mjs} +14 -7
- package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
- package/docs/publish.md +27 -5
- package/docs/sticky-notes.md +43 -0
- package/package.json +2 -1
- package/src/client/content/diagram.tsx +27 -1
- package/src/client/content/index.tsx +2 -2
- package/src/client/content/md.ts +48 -0
- package/src/client/frame-host/bridge.js +4 -9
- package/src/client/frame-host/main.tsx +20 -6
- package/src/client/shell/App.tsx +14 -63
- package/src/client/shell/Comments.tsx +77 -14
- package/src/client/shell/Play.tsx +6 -3
- package/src/client/shell/canvas/Canvas.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +99 -89
- package/src/client/shell/canvas/Sticky.tsx +284 -0
- package/src/client/shell/canvas/admission.ts +70 -0
- package/src/client/shell/canvas/sleep.ts +194 -0
- package/src/client/shell/goto.ts +72 -0
- package/src/client/shell/notes.ts +151 -0
- package/src/client/shell/store.ts +36 -18
- package/src/client/shell/styles.css +92 -25
- package/src/client/shell/tidy.ts +20 -2
- package/src/shared/sleep-rule.ts +41 -0
- package/templates/AGENTS-embedded.md +15 -0
- package/templates/AGENTS-studio.md +15 -0
- package/templates/instructions/craft.md +9 -0
- package/templates/instructions/publish.md +62 -9
- package/templates/instructions/shape.md +62 -0
- package/src/client/frame-host/serialize.ts +0 -195
- 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 {
|
|
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-
|
|
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 { a as
|
|
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-
|
|
334
|
-
const { ensurePoster } = await import("./poster-
|
|
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 };
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
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(///gi, '/').replace(/\/gi, '\\').replace(/:/gi, ':').replace(/"/gi, '"').replace(/'/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
|
-
|
|
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
|
|
package/src/client/content/md.ts
CHANGED
|
@@ -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 (`©`, `A`) 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
|
-
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
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 }))
|