@marver-design/marver 0.17.0 → 0.18.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 (36) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +1 -0
  3. package/dist/bake-jr38C_pX.mjs +747 -0
  4. package/dist/{build-D_g53Bp2.mjs → build-ER_7T6Cw.mjs} +179 -10
  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-CKhg0zuT.mjs} +1 -1
  8. package/dist/{dev-LnIISva5.mjs → dev-BvLbY98O.mjs} +204 -10
  9. package/dist/{init-B7YhcN2o.mjs → init-BWbHqVng.mjs} +2 -2
  10. package/dist/{manifest-CaslQIAO.mjs → manifest-DAnEL8_a.mjs} +1 -1
  11. package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
  12. package/dist/{plugin-D2msH1cj.mjs → plugin-Cai5C1WV.mjs} +107 -28
  13. package/dist/{poster-BEjUcQP3.mjs → poster-CIuz_PwH.mjs} +1 -1
  14. package/dist/publish-bakes-D0LhbQQ3.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-iicees2e.mjs} +14 -7
  19. package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
  20. package/docs/publish.md +27 -5
  21. package/package.json +1 -1
  22. package/src/client/frame-host/bridge.js +4 -9
  23. package/src/client/frame-host/main.tsx +20 -6
  24. package/src/client/shell/App.tsx +7 -0
  25. package/src/client/shell/Comments.tsx +2 -2
  26. package/src/client/shell/canvas/Canvas.tsx +2 -2
  27. package/src/client/shell/canvas/FrameNode.tsx +82 -89
  28. package/src/client/shell/canvas/admission.ts +70 -0
  29. package/src/client/shell/canvas/sleep.ts +194 -0
  30. package/src/client/shell/store.ts +14 -0
  31. package/src/client/shell/styles.css +9 -25
  32. package/src/shared/sleep-rule.ts +41 -0
  33. package/templates/instructions/craft.md +9 -0
  34. package/templates/instructions/publish.md +62 -9
  35. package/src/client/frame-host/serialize.ts +0 -195
  36. package/src/client/shell/canvas/snapshots.ts +0 -233
@@ -1,8 +1,8 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
- import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
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-CaslQIAO.mjs";
5
+ import { a as writeManifest, n as scanFrames, r as setSceneTitle, s as loadConfig, t as affectedFrameIds } from "./manifest-DAnEL8_a.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-DlmTO8AF.mjs");
425
+ const { shootFrame } = await import("./shot-iicees2e.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-DlmTO8AF.mjs");
475
+ const { resolveFrames, shootBatch } = await import("./shot-iicees2e.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"}`;
@@ -489,11 +489,63 @@ function apiMiddleware(root, opts = {}) {
489
489
  return json(res, 503, { error: err.message });
490
490
  }
491
491
  }
492
+ if (path === "bakes" && req.method === "POST") {
493
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
494
+ const raw = await readBody(req);
495
+ if (raw == null) return json(res, 400, { error: "body too large or unreadable" });
496
+ let body;
497
+ try {
498
+ body = JSON.parse(raw);
499
+ } catch {
500
+ return json(res, 400, { error: "malformed JSON" });
501
+ }
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");
504
+ let manifest = {};
505
+ try {
506
+ manifest = JSON.parse(readFileSync(join(root, "design", "manifest.json"), "utf8"));
507
+ } catch {}
508
+ const frames = new Map((manifest.frames ?? []).map((f) => [f.id, f]));
509
+ const asks = [];
510
+ for (const a of body.asks) {
511
+ const frame = typeof a?.frame === "string" ? a.frame : "";
512
+ const theme = typeof a?.theme === "string" && /^[a-z0-9-]{1,32}$/i.test(a.theme) ? a.theme : "";
513
+ const w = Math.round(Number(a?.w)), h = Math.round(Number(a?.h));
514
+ if (!frames.has(frame) || !theme || !(w >= 120 && h >= 80 && w <= ASK_MAX.side && h <= ASK_MAX.side && w * h <= ASK_MAX.area)) return json(res, 400, { error: `invalid ask for "${frame}"` });
515
+ asks.push({
516
+ frame,
517
+ theme,
518
+ w,
519
+ h
520
+ });
521
+ }
522
+ const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
523
+ const { ROUTE } = await import("./cli.mjs").then((n) => n.t);
524
+ const gen = opts.bakeGen?.() ?? 0;
525
+ return json(res, 200, {
526
+ gen,
527
+ answers: await bakeBatch({
528
+ root,
529
+ gen,
530
+ asks,
531
+ live: () => opts.bakeGen?.() ?? 0,
532
+ log: (a) => {
533
+ if (a.ok) console.log(` bake: ${a.frame} ${a.theme} ${a.w}x${a.h} - ${a.targets.length} effects, ${a.rejected} stay live, ${a.ms} ms`);
534
+ else console.log(` bake: ${a.frame} ${a.theme} ${a.w}x${a.h} - stays live: ${a.error}`);
535
+ },
536
+ urlFor: (ask) => {
537
+ const f = frames.get(ask.frame);
538
+ return f.kind === "html" ? `${origin}/${f.file}?theme=${encodeURIComponent(ask.theme)}` : `${origin}${ROUTE}/frame/?id=${encodeURIComponent(ask.frame)}&theme=${encodeURIComponent(ask.theme)}`;
539
+ },
540
+ urlBase: `${ROUTE}/bakes`
541
+ })
542
+ });
543
+ }
492
544
  if (path === "poster" && req.method === "GET") {
493
545
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
494
546
  const src = url.searchParams.get("src") ?? "";
495
- const { ensurePoster, isLocalClip } = await import("./poster-BEjUcQP3.mjs");
496
- const { isLocalAssetRef } = await import("./build-D_g53Bp2.mjs");
547
+ const { ensurePoster, isLocalClip } = await import("./poster-CIuz_PwH.mjs");
548
+ const { isLocalAssetRef } = await import("./build-ER_7T6Cw.mjs");
497
549
  if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
498
550
  const r = await ensurePoster(join(root, "design", "assets"), src);
499
551
  if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
@@ -681,6 +733,24 @@ function routesMiddleware(server, clientDir) {
681
733
  } catch {
682
734
  return next();
683
735
  }
736
+ } else if (path.startsWith(`/__mv/bakes/`)) {
737
+ const m = /^\/(\d+)\/([a-f0-9]{16})\/(\d+)\.png$/.exec(path.slice(`${ROUTE}/bakes`.length));
738
+ if (!m) {
739
+ res.statusCode = 404;
740
+ return res.end();
741
+ }
742
+ try {
743
+ const base = realpathSync(join(server.config.root, "design", ".local", "bakes"));
744
+ const file = realpathSync(join(base, m[1], m[2], `${m[3]}.png`));
745
+ if (!file.startsWith(base + sep)) throw new Error("outside the cache");
746
+ const png = readFileSync(file);
747
+ res.setHeader("content-type", "image/png");
748
+ res.setHeader("cache-control", "public, max-age=31536000, immutable");
749
+ return res.end(png);
750
+ } catch {
751
+ res.statusCode = 404;
752
+ return res.end();
753
+ }
684
754
  } else if (path === `/__mv/bridge.js`) {
685
755
  res.setHeader("content-type", "text/javascript");
686
756
  return res.end(readFileSync(join(clientDir, "frame-host", "bridge.js"), "utf8"));
@@ -846,27 +916,19 @@ function marverPlugin(ctx) {
846
916
  const bridge = "/@fs/" + join(clientDir, "frame-host", "bridge.js").split("\\").join("/") + "?html=1";
847
917
  return {
848
918
  html,
849
- tags: [
850
- {
851
- tag: "script",
852
- children: `(function(){var a=Element.prototype.attachShadow;if(a)Element.prototype.attachShadow=function(i){if(i&&i.mode==='closed')window.__mvClosedShadow=1;return a.call(this,i)};})();`,
853
- injectTo: "head-prepend"
919
+ tags: [{
920
+ tag: "script",
921
+ attrs: { type: "module" },
922
+ children: `import '${VIRTUAL_THEME}'`,
923
+ injectTo: "head-prepend"
924
+ }, {
925
+ tag: "script",
926
+ attrs: {
927
+ type: "module",
928
+ src: bridge
854
929
  },
855
- {
856
- tag: "script",
857
- attrs: { type: "module" },
858
- children: `import '${VIRTUAL_THEME}'`,
859
- injectTo: "head-prepend"
860
- },
861
- {
862
- tag: "script",
863
- attrs: {
864
- type: "module",
865
- src: bridge
866
- },
867
- injectTo: "head-prepend"
868
- }
869
- ]
930
+ injectTo: "head-prepend"
931
+ }]
870
932
  };
871
933
  }
872
934
  },
@@ -888,6 +950,7 @@ function marverPlugin(ctx) {
888
950
  return [];
889
951
  },
890
952
  configureServer(server) {
953
+ let bakeGen = Date.now();
891
954
  server.middlewares.use((req, res, next) => {
892
955
  if (req.url && req.url.includes("/frame-host/registry.ts")) {
893
956
  const orig = res.setHeader.bind(res);
@@ -917,7 +980,8 @@ function marverPlugin(ctx) {
917
980
  };
918
981
  server.middlewares.use(apiMiddleware(root, {
919
982
  viewports: config.viewports,
920
- origin: listeningOrigin
983
+ origin: listeningOrigin,
984
+ bakeGen: () => bakeGen
921
985
  }));
922
986
  server.middlewares.use(routesMiddleware(server, clientDir));
923
987
  devServer = server;
@@ -955,6 +1019,21 @@ function marverPlugin(ctx) {
955
1019
  for (const mod of themeModules()) server.moduleGraph.invalidateModule(mod);
956
1020
  pushTheme();
957
1021
  };
1022
+ const localDir = join(root, "design", ".local"), boardsDirGen = join(root, "design", "boards"), manifestPath = join(root, "design", "manifest.json");
1023
+ const bumpGen = (f) => {
1024
+ if (f.startsWith(localDir) || f.startsWith(boardsDirGen) || f === manifestPath || f.includes("/node_modules/")) return;
1025
+ bakeGen++;
1026
+ prune();
1027
+ };
1028
+ const prune = () => setTimeout(async () => {
1029
+ try {
1030
+ (await import("./bake-jr38C_pX.mjs")).pruneBakes(root, bakeGen);
1031
+ } catch {}
1032
+ }, 500);
1033
+ prune();
1034
+ server.watcher.on("add", bumpGen);
1035
+ server.watcher.on("unlink", bumpGen);
1036
+ server.watcher.on("change", bumpGen);
958
1037
  server.watcher.on("add", (f) => {
959
1038
  if (inScope(f)) {
960
1039
  regen();
@@ -1087,7 +1166,7 @@ function marverPlugin(ctx) {
1087
1166
  });
1088
1167
  return;
1089
1168
  }
1090
- const { shootFrame, shootBatch, resolveFrames } = await import("./shot-DlmTO8AF.mjs");
1169
+ const { shootFrame, shootBatch, resolveFrames } = await import("./shot-iicees2e.mjs");
1091
1170
  if (ways[0] === "frame") {
1092
1171
  if (typeof spec.frame !== "string") {
1093
1172
  write({
@@ -1,4 +1,4 @@
1
- import { capture, t as findChrome } from "./shot-DlmTO8AF.mjs";
1
+ import { capture, t as findChrome } from "./shot-iicees2e.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";
@@ -0,0 +1,216 @@
1
+ import { planShot, t as findChrome } from "./shot-iicees2e.mjs";
2
+ import { ASK_MAX, bakeBatch } from "./bake-jr38C_pX.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-ER_7T6Cw.mjs");
341
+ const { ensurePoster } = await import("./poster-CIuz_PwH.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.17.0",
3
+ "version": "0.18.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,
@@ -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')