visualproof 0.1.0 → 0.3.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 (94) hide show
  1. package/README.md +40 -26
  2. package/dist/assets/webfonts/LICENSE-Jost.txt +93 -0
  3. package/dist/assets/webfonts/LICENSE-Noto.txt +95 -0
  4. package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
  5. package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
  6. package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
  7. package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
  8. package/dist/assets/webfonts/metrics.json +1 -0
  9. package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
  10. package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
  11. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
  12. package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  13. package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  14. package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  15. package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
  16. package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
  17. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
  18. package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
  19. package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  20. package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  21. package/dist/assets/webfonts/ttf/jost-400-normal.ttf +0 -0
  22. package/dist/assets/webfonts/ttf/jost-500-normal.ttf +0 -0
  23. package/dist/assets/webfonts/ttf/noto-sans-math.ttf +0 -0
  24. package/dist/assets/webfonts/ttf/noto-sans.ttf +0 -0
  25. package/dist/assets/webfonts/ttf/source-sans-3-400-italic.ttf +0 -0
  26. package/dist/assets/webfonts/ttf/source-sans-3-400-normal.ttf +0 -0
  27. package/dist/assets/webfonts/ttf/source-sans-3-500-normal.ttf +0 -0
  28. package/dist/assets/webfonts/ttf/source-sans-3-600-normal.ttf +0 -0
  29. package/dist/assets/webfonts/ttf/source-sans-3-700-normal.ttf +0 -0
  30. package/dist/assets/webfonts/ttf/source-serif-4-400-italic.ttf +0 -0
  31. package/dist/assets/webfonts/ttf/source-serif-4-600-italic.ttf +0 -0
  32. package/dist/server/agent.js +125 -92
  33. package/dist/server/agentdocs.js +1 -1
  34. package/dist/server/artcmd.js +335 -0
  35. package/dist/server/history.js +39 -0
  36. package/dist/server/historyapi.js +2 -2
  37. package/dist/server/hover.js +3 -3
  38. package/dist/server/layoutthread.js +1 -1
  39. package/dist/server/live.js +6 -0
  40. package/dist/server/main.js +151 -11
  41. package/dist/server/mcptools.js +6 -4
  42. package/dist/server/pastdoc.js +1 -1
  43. package/dist/server/pipeline.js +18 -9
  44. package/dist/server/readonlygate.js +2 -2
  45. package/dist/server/review.js +4 -1
  46. package/dist/server/reviewapi.js +35 -1
  47. package/dist/server/snapshot.js +13 -3
  48. package/dist/server/usage.js +2 -2
  49. package/dist/server/views.js +25 -1
  50. package/dist/shared/artfonts.js +81 -0
  51. package/dist/shared/artrecipe.js +316 -0
  52. package/dist/shared/collab.js +99 -1
  53. package/dist/shared/declfind.js +95 -0
  54. package/dist/shared/doc/order.js +17 -0
  55. package/dist/shared/doc/outline.js +45 -0
  56. package/dist/shared/keys.js +1 -1
  57. package/dist/shared/lostfound.js +1 -0
  58. package/dist/shared/moves.js +2 -1
  59. package/dist/shared/pastgraph.js +9 -1
  60. package/dist/shared/query.js +21 -13
  61. package/dist/shared/review.js +62 -5
  62. package/dist/shared/sharesnap.js +2 -1
  63. package/dist/shared/titles.js +2 -0
  64. package/dist/shared/viewgraph.js +18 -0
  65. package/dist/web/bundle/app.js +7709 -3222
  66. package/dist/web/bundle/edges.js +4 -6
  67. package/dist/web/index.html +24 -6
  68. package/dist/web/src/agentmark.js +14 -0
  69. package/dist/web/src/artwork.js +1000 -0
  70. package/dist/web/src/bundles.js +151 -0
  71. package/dist/web/src/canvas.js +3694 -0
  72. package/dist/web/src/drag.js +61 -0
  73. package/dist/web/src/edgequeue.js +204 -0
  74. package/dist/web/src/gl/gl.js +123 -0
  75. package/dist/web/src/gl/renderer.js +451 -0
  76. package/dist/web/src/halo.js +95 -0
  77. package/dist/web/src/highlight.js +108 -0
  78. package/dist/web/src/overview.js +393 -0
  79. package/dist/web/src/reviewqueue.js +250 -0
  80. package/dist/web/src/scene.js +17 -0
  81. package/dist/web/src/selection.js +272 -0
  82. package/dist/web/src/status.js +80 -0
  83. package/dist/web/src/visitors.js +318 -0
  84. package/dist/web/src/visits.js +174 -0
  85. package/dist/web/style.css +255 -12
  86. package/docs/agent-api/README.md +1 -1
  87. package/docs/agent-api/export.md +41 -2
  88. package/docs/agent-api/following.md +2 -2
  89. package/docs/agent-api/history.md +2 -1
  90. package/docs/agent-api/mcp.md +1 -1
  91. package/docs/agent-api/reading.md +2 -1
  92. package/docs/agent-api/views.md +3 -2
  93. package/package.json +3 -1
  94. package/test/toolchains.json +2 -2
@@ -2,12 +2,15 @@
2
2
  // visualproof serve <path> — read a built Lean project and serve its declaration graph
3
3
  // visualproof export <path> — the same page, frozen into one HTML file
4
4
  import { historyAnswer } from "./historyapi.js";
5
+ import { gitSource } from "./history.js";
5
6
  import { createHash } from "node:crypto";
6
- import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
8
  import { createServer } from "node:http";
8
- import { extname, join, resolve } from "node:path";
9
+ import { basename, dirname, extname, join, resolve } from "node:path";
10
+ import { tmpdir } from "node:os";
9
11
  import { gunzipSync, gzipSync } from "node:zlib";
10
- import { WEB, bundleDir, bundleDirIn, ensureBundle, prepare, useBundleDir } from "./pipeline.js";
12
+ import { WEB, WEBFONTS, bundleDir, bundleDirIn, ensureBundle, prepare, useBundleDir } from "./pipeline.js";
13
+ import { makeArtwork, pngOf, projectView } from "./artcmd.js";
11
14
  import { SiteBusy, cacheHome, dataHome, lockSite, openSite, projectDirs } from "./dirs.js";
12
15
  import { ProjectError, openProject, targetsInWords } from "./project.js";
13
16
  import { Live } from "./live.js";
@@ -15,8 +18,8 @@ import { ReviewStore } from "./review.js";
15
18
  import { isDeclName, LeanHover } from "./hover.js";
16
19
  import { Identities, layoutAuthor } from "./identity.js";
17
20
  import { LAYOUT_VERSION } from "../shared/graph.js";
18
- import { MAX_MEMORY_BYTES, Views } from "./views.js";
19
- import { cleanView, defaultAgentName, validSession } from "../shared/collab.js";
21
+ import { MAX_MEMORY_BYTES, readProjectView, Views } from "./views.js";
22
+ import { cleanView, defaultAgentName, validSession, withoutArtwork } from "../shared/collab.js";
20
23
  import { writeSnapshot } from "./snapshot.js";
21
24
  import { lakeTextHash } from "./status.js";
22
25
  import { invalidTemplate } from "../shared/editor.js";
@@ -48,6 +51,8 @@ function usage(code = 2) {
48
51
  visualproof purge-file <lean-project> <sha256> [--site dir] [--cache dir]
49
52
  visualproof export <lean-project> -o page.html [--label text] [--root Name]... [--graph saved.json]
50
53
  [--jobs ${DEFAULT_JOBS}] [--site dir] [--cache dir]
54
+ visualproof artwork <lean-project> --recipe recipe.json [-o name] [--png] [--svg] [--root Name]...
55
+ [--site dir] [--cache dir] [--jobs ${DEFAULT_JOBS}] [--screen 1920x1200] [--graph saved.json]
51
56
  visualproof doctor [<lean-project>] [--root Name]... [--site dir] [--cache dir]
52
57
  visualproof --version
53
58
 
@@ -72,6 +77,17 @@ function usage(code = 2) {
72
77
  the build; the project path still supplies the sources
73
78
  export writes one self-contained HTML file: the page, its data and the sources, frozen
74
79
 
80
+ artwork makes the picture an artwork recipe says (the agents' API's, docs/agent-api/export.md:
81
+ the size, the look, names by a rule, the targets, the selection), as the page's
82
+ artwork mode makes it, without a browser: an SVG and a PNG (--png or --svg for one),
83
+ named by -o (a name, or a path without its extension), else the recipe's title, else
84
+ the project. With --site, the board as that site keeps it (its layout, pins and folds)
85
+ and its review's targets; without, the board as a new site would lay it out, and the
86
+ targets the recipe names. --screen: the screen the board is drawn on, as a reader's
87
+ (default 1920x1200, at least 640x480): how large names are in the picture. It reads
88
+ the site, never writes it; it writes the files and the cache's extraction. Anything
89
+ that fails ends it with status 1. Notes are not drawn yet.
90
+
75
91
  --identity-header behind a proxy that authenticates (Tailscale, oauth2-proxy, …): take each
76
92
  reader's name from this header instead of the name the page gives. Only use it when
77
93
  the proxy is the only way to reach the server: the header is believed as it comes.
@@ -131,9 +147,9 @@ function parseArgs(argv) {
131
147
  console.log(packageVersion());
132
148
  process.exit(0);
133
149
  }
134
- if (cmd !== "serve" && cmd !== "export" && cmd !== "purge-file" && cmd !== "doctor")
150
+ if (cmd !== "serve" && cmd !== "export" && cmd !== "purge-file" && cmd !== "doctor" && cmd !== "artwork")
135
151
  usage();
136
- const o = { cmd, path: "", roots: [], port: 4173, host: "127.0.0.1", site: null, cache: null, graph: null, out: null, label: "VisualProof", identityHeader: null, editor: null, agents: "full", accessKeyFile: null, jobs: DEFAULT_JOBS, publicUrl: null, libraryLimit: DEFAULT_LIBRARY_LIMIT, sha: null, publicRead: false };
152
+ const o = { cmd, path: "", roots: [], port: 4173, host: "127.0.0.1", site: null, cache: null, graph: null, out: null, label: "VisualProof", identityHeader: null, editor: null, agents: "full", accessKeyFile: null, jobs: DEFAULT_JOBS, publicUrl: null, libraryLimit: DEFAULT_LIBRARY_LIMIT, sha: null, publicRead: false, recipe: null, png: false, svg: false, screen: { w: 1920, h: 1200 } };
137
153
  for (let i = 0; i < rest.length; i++) {
138
154
  const a = rest[i];
139
155
  const val = () => rest[++i] ?? usage();
@@ -161,6 +177,18 @@ function parseArgs(argv) {
161
177
  o.accessKeyFile = resolve(val());
162
178
  else if (a === "--public")
163
179
  o.publicRead = true;
180
+ else if (a === "--recipe" && cmd === "artwork")
181
+ o.recipe = resolve(val());
182
+ else if (a === "--png" && cmd === "artwork")
183
+ o.png = true;
184
+ else if (a === "--svg" && cmd === "artwork")
185
+ o.svg = true;
186
+ else if (a === "--screen" && cmd === "artwork") {
187
+ const m = /^(\d+)x(\d+)$/.exec(val());
188
+ if (!m || +m[1] < 640 || +m[2] < 480 || +m[1] > 10000 || +m[2] > 10000)
189
+ usage();
190
+ o.screen = { w: +m[1], h: +m[2] };
191
+ }
164
192
  else if (a === "--public-url") {
165
193
  const u = val();
166
194
  if (!/^https?:\/\/[^\s/?#]+\/?$/.test(u)) {
@@ -196,7 +224,7 @@ function parseArgs(argv) {
196
224
  else
197
225
  usage();
198
226
  }
199
- if ((!o.path && cmd !== "doctor") || (cmd === "export" && !o.out))
227
+ if ((!o.path && cmd !== "doctor") || (cmd === "export" && !o.out) || (cmd === "artwork" && !o.recipe))
200
228
  usage();
201
229
  // read-only for whoever comes, beside those the key or a link lets in: without a key, everyone is in
202
230
  // already; behind a sign-in, there is nobody else
@@ -221,6 +249,11 @@ export class App {
221
249
  live;
222
250
  hover;
223
251
  ids;
252
+ /** how long a read of the project's git (GET /api/git) is kept, whoever asks: git is run at most
253
+ * once in that time, so what readers cost the server is bounded (CollabSpec decision 11); 0 in
254
+ * the tests */
255
+ gitFreshMs = 3000;
256
+ gitRead = null;
224
257
  /** the agents' API (server/agent.ts), and what agents may change */
225
258
  agents;
226
259
  /** who may come in at all (server/access.ts); null: anyone who reaches the port */
@@ -265,6 +298,11 @@ export class App {
265
298
  async export(req, res, url, agent, readOnly = false) {
266
299
  const path = url.pathname;
267
300
  const api = this.agents;
301
+ // the document's outline, which the linear form draws: nothing is typeset for it
302
+ if (path === "/api/export/outline") {
303
+ const o = api.exportOutline(url.searchParams.get("view") ?? "", url.searchParams.get("version") ?? undefined);
304
+ return sendJson(res, o.body, o.status, o.headers);
305
+ }
268
306
  if (!this.exporter)
269
307
  return sendJson(res, { error: "This server does not export documents." }, 404);
270
308
  let a;
@@ -817,6 +855,9 @@ export class App {
817
855
  return sendFile(req, res, join(WEB, "index.html"));
818
856
  if (path === "/style.css")
819
857
  return sendFile(req, res, join(WEB, "style.css"));
858
+ // the fonts an artwork is drawn in (ExportSpec decision 19)
859
+ if (FONT_PATH.test(path))
860
+ return sendFile(req, res, join(WEBFONTS, path.slice("/fonts/".length)));
820
861
  // the page, and its workers: the one that lays out the Time Machine's past graphs
821
862
  // (web/src/layoutworker.ts), the one that chooses the overview's edges (web/src/edgeworker.ts),
822
863
  // and pdf.js's, which reads the library's PDFs for the reference window (AttachmentSpec decision 11)
@@ -937,8 +978,9 @@ export class App {
937
978
  // its own event stream only, by the secret the stream was given (`hello`)
938
979
  const name = this.ids.who(req);
939
980
  const priv = !agent && b.private === true;
981
+ // agents have no artwork (ExportSpec decision 19): the page draws it
940
982
  if (agent && name)
941
- live.presence.agentBeat(name, cleanView(b.view));
983
+ live.presence.agentBeat(name, withoutArtwork(cleanView(b.view)));
942
984
  else if (!live.beat(b.token, priv ? null : name, priv ? null : cleanView(b.view), !priv && b.idle === true, priv, !!this.ids.person(req)))
943
985
  return sendJson(res, { error: "This heartbeat is not from an open event stream of this page's: it is sent with the token the stream says hello with.", stream: false }, 403);
944
986
  return sendJson(res, { ok: true });
@@ -967,6 +1009,13 @@ export class App {
967
1009
  const a = this.agents.undo(me, agent, r.body?.id);
968
1010
  return sendJson(res, a.body, a.status);
969
1011
  }
1012
+ // the project's git source and commit as git says them now, not as the build was read (an
1013
+ // artwork's foot, ExportSpec decision 19; the owner's decision of 2026-10-15)
1014
+ if (path === "/api/git") {
1015
+ if (!this.gitRead || Date.now() - this.gitRead.at >= this.gitFreshMs)
1016
+ this.gitRead = { git: gitSource(live.project.root), at: Date.now() };
1017
+ return sendJson(res, { git: this.gitRead.git });
1018
+ }
970
1019
  if (path === "/api/meta" || path === "/api/edges") {
971
1020
  const g = live.graph;
972
1021
  if (!g)
@@ -1080,7 +1129,10 @@ export class App {
1080
1129
  }
1081
1130
  }
1082
1131
  }
1132
+ /** an artwork's font file (assets/webfonts/) */
1133
+ const FONT_PATH = /^\/fonts\/(source-sans-3|source-serif-4|jost)-[a-z-]+\.woff2$/;
1083
1134
  const TYPES = {
1135
+ ".woff2": "font/woff2",
1084
1136
  ".html": "text/html; charset=utf-8",
1085
1137
  ".css": "text/css; charset=utf-8",
1086
1138
  ".js": "text/javascript; charset=utf-8",
@@ -1282,8 +1334,10 @@ async function main() {
1282
1334
  console.log(printReport(report, o.path || null));
1283
1335
  process.exit(report.checks.some((c) => c.level === "fail") ? 1 : 0);
1284
1336
  }
1285
- // a live server outlives any one failed task: log it and keep serving
1286
- process.on("unhandledRejection", (e) => console.error("unhandled:", e instanceof Error ? e.message : e));
1337
+ // a live server outlives any one failed task: log it and keep serving (a picture is no server:
1338
+ // whatever fails ends it, with status 1)
1339
+ if (o.cmd !== "artwork")
1340
+ process.on("unhandledRejection", (e) => console.error("unhandled:", e instanceof Error ? e.message : e));
1287
1341
  let project;
1288
1342
  try {
1289
1343
  project = openProject(o.path, o.roots, o.graph === null);
@@ -1295,6 +1349,9 @@ async function main() {
1295
1349
  }
1296
1350
  throw e;
1297
1351
  }
1352
+ // a picture: the site read, never made, locked or written (ExportSpec decision 21)
1353
+ if (o.cmd === "artwork")
1354
+ return artworkCommand(project, o);
1298
1355
  const dirs = projectDirs(project.root, o.site, o.cache);
1299
1356
  openSite(dirs.site);
1300
1357
  mkdirSync(dirs.cache, { recursive: true });
@@ -1389,6 +1446,89 @@ async function main() {
1389
1446
  });
1390
1447
  live.start();
1391
1448
  }
1449
+ /** The artwork command (ExportSpec decision 21): the build read as an export reads it, the picture a
1450
+ * recipe says made of it (server/artcmd.ts), its files written; what it named said as the agents'
1451
+ * API says it; problems said, and exit status 1. */
1452
+ async function artworkCommand(project, o) {
1453
+ const fail = (why) => {
1454
+ console.error(`visualproof artwork: ${why}`);
1455
+ process.exit(1);
1456
+ };
1457
+ try {
1458
+ await artwork(project, o, fail);
1459
+ }
1460
+ catch (e) {
1461
+ fail(e instanceof Error ? e.message : String(e));
1462
+ }
1463
+ }
1464
+ async function artwork(project, o, fail) {
1465
+ const dirs = projectDirs(project.root, o.site, o.cache);
1466
+ const site = o.site ? dirs.site : null;
1467
+ if (site && !existsSync(site))
1468
+ fail(`there is no site at ${site}`);
1469
+ let recipe;
1470
+ try {
1471
+ recipe = JSON.parse(readFileSync(o.recipe, "utf8"));
1472
+ }
1473
+ catch (e) {
1474
+ fail(`the recipe ${o.recipe} cannot be read: ${e instanceof Error ? e.message : String(e)}`);
1475
+ }
1476
+ // -o: a name, or a path without its extension, in a folder that is there
1477
+ const outBase = o.out ? o.out.replace(/\.(png|svg)$/i, "") : null;
1478
+ const dir = outBase ? dirname(outBase) : process.cwd();
1479
+ if (!existsSync(dir))
1480
+ fail(`there is no folder ${dir} to write the files in`);
1481
+ mkdirSync(dirs.cache, { recursive: true });
1482
+ const log = (l) => console.log(l);
1483
+ let ex = o.graph ? undefined : await extract(project, dirs, log);
1484
+ if (ex && o.jobs > 0) {
1485
+ const pass = new UsagePass(project, dirs, o.jobs, log);
1486
+ const plan = pass.plan(ex.graph);
1487
+ if (plan.todo.length)
1488
+ log(`usage pass: elaborating ${plan.todo.length} of ${plan.modules.length} modules, ${o.jobs} at a time`);
1489
+ const results = await pass.run(plan);
1490
+ if (results) {
1491
+ ex = mergeUsage(ex, plan, results, pass.status.failed, pass.status.ms ?? 0);
1492
+ saveMerged(dirs.cache, ex);
1493
+ }
1494
+ }
1495
+ // the site's layout memory read and never written, nor the views laid out written to the cache;
1496
+ // without a site, none read: the board as a new site lays it out
1497
+ const prep = await prepare(project, dirs, o.graph, log, { ...(ex ? { extraction: ex } : {}), readOnlySite: true, ...(site ? {} : { freshLayout: true }) });
1498
+ const kept = site ? readProjectView(site, LAYOUT_VERSION) : { view: null };
1499
+ if (kept.why)
1500
+ console.error(`visualproof artwork: the site's views cannot be read (${kept.why}): the Project view as a new site has it`);
1501
+ const review = site ? new ReviewStore(project.root, site).data : null;
1502
+ const a = makeArtwork(prep, review, projectView(kept.view), { recipe, screen: o.screen, name: outBase ? basename(outBase) : null, root: project.root });
1503
+ if (!("svg" in a)) {
1504
+ for (const p of a.problems)
1505
+ console.error(`visualproof artwork: ${p}`);
1506
+ process.exit(1);
1507
+ }
1508
+ const list = (xs) => (xs.length ? xs.join(", ") : "none");
1509
+ console.log(`names written: ${list(a.names.written)}`);
1510
+ if (a.names.ruledSkipped.length)
1511
+ console.log(`names a rule chose, left out where they would cover another: ${list(a.names.ruledSkipped)}`);
1512
+ if (a.names.left)
1513
+ console.log(`names or targets left out for room: ${a.names.left}`);
1514
+ console.log(`targets marked: ${list(a.targets)}`);
1515
+ console.log(`selection: ${a.selection ? `${a.selection.mode} of ${a.selection.name}` : "none"}`);
1516
+ if (a.notesLeftOut)
1517
+ console.log(`notes: ${a.notesLeftOut} on the board, not drawn (the command draws no notes yet)`);
1518
+ const both = !o.png && !o.svg;
1519
+ const mb = (n) => `${(n / 1e6).toFixed(1)} MB`;
1520
+ if (both || o.svg) {
1521
+ const file = join(dir, a.file("svg"));
1522
+ writeFileSync(file, a.svg);
1523
+ console.log(`wrote ${file} (${mb(Buffer.byteLength(a.svg))})`);
1524
+ }
1525
+ if (both || o.png) {
1526
+ const png = await pngOf(a.svg);
1527
+ const file = join(dir, a.file("png"));
1528
+ writeFileSync(file, png);
1529
+ console.log(`wrote ${file} (${mb(png.length)}, ${a.look.w} × ${a.look.h})`);
1530
+ }
1531
+ }
1392
1532
  // run as the command (`node src/server/main.ts`, the `visualproof` bin), not when a test imports App
1393
1533
  if (import.meta.main)
1394
1534
  void main();
@@ -149,7 +149,7 @@ export const TOOLS = [
149
149
  default: "move",
150
150
  actions: {
151
151
  move: { method: "POST", path: "/api/agent/view", says: "go to a view, a target's view, a declaration, its source; fields left out keep their value", args: ["view", "review", "sel", "selection", "mode", "source", "paper", "focus", "leave"] },
152
- link: { method: "POST", path: "/api/agent/view/link", says: "a link that opens exactly what you look at, or a place you describe, to hand people in a reply", args: ["view", "review", "sel", "selection", "mode", "source", "paper", "focus", "sourceCollapsed", "pinned", "history", "region", "activity", "lostFound", "scheme", "edges", "proofEdges", "exact"] },
152
+ link: { method: "POST", path: "/api/agent/view/link", says: "a link that opens exactly what you look at, or a place you describe, to hand people in a reply", args: ["view", "review", "sel", "selection", "mode", "source", "paper", "focus", "sourceCollapsed", "pinned", "history", "region", "activity", "lostFound", "scheme", "edges", "exact"] },
153
153
  ask_follow: { method: "POST", path: "/api/agent/ask-follow", says: "ask the people on the page to follow you first", args: ["who", "wait"] },
154
154
  },
155
155
  args: {
@@ -157,7 +157,7 @@ export const TOOLS = [
157
157
  review: str("a target: be on its view"),
158
158
  sel: str("the declaration you look at"),
159
159
  selection: str("what you select, as a query: decl(A.b) or tcb(A.c)"),
160
- mode: str("how you select: plain; Rests on's deps, tcb, keys; apps", ["plain", "deps", "tcb", "keys", "apps"]),
160
+ mode: str("how you select: plain; Rests on's deps, tcb, keys, refs (what it uses directly); apps", ["plain", "deps", "tcb", "keys", "refs", "apps"]),
161
161
  source: bool("the source window open on sel"),
162
162
  paper: PAPER,
163
163
  focus: str("your focus on the view: a target, \"none\", or \"view\" for the view's own"),
@@ -170,7 +170,6 @@ export const TOOLS = [
170
170
  lostFound: bool("link: the lost-and-found open"),
171
171
  scheme: str("link: the colouring", ["kind", "status"]),
172
172
  edges: bool("link: edges drawn"),
173
- proofEdges: bool("link: proof edges drawn"),
174
173
  exact: bool("link: false for the view's address alone"),
175
174
  who: str("ask_follow: a person's name; left out, everyone"),
176
175
  wait: bool("ask_follow: false answers at once (default: wait for them, at most 30 s)"),
@@ -374,12 +373,13 @@ export const TOOLS = [
374
373
  },
375
374
  {
376
375
  name: "export",
377
- about: "A view's document — every declaration of the project its board holds, in its sections and order: its model as JSON, or typeset as a PDF and saved on your machine.",
376
+ about: "A view's document — every declaration of the project its board holds, in its sections and order: its model as JSON, or typeset as a PDF and saved on your machine. Or a picture of the board: a link that opens it in the artwork mode as a recipe says.",
378
377
  actions: {
379
378
  model: { method: "GET", path: "/api/agent/export", says: "the document's model", args: ["view", "settings", "version"] },
380
379
  typeset: { method: "POST", path: "/api/agent/export", says: "start typesetting: a job", args: ["view", "settings", "version"] },
381
380
  job: { method: "GET", path: "/api/agent/export/job", says: "how the job is doing", args: ["id"] },
382
381
  file: { method: "GET", path: "/api/agent/export/file", saves: true, says: "a file of it saved on your machine", args: ["id", "what", "saveTo"] },
382
+ artwork: { method: "POST", path: "/api/agent/artwork", says: "a link to a picture of the board, as a recipe says", args: ["recipe", "save"] },
383
383
  },
384
384
  args: {
385
385
  view: str("the view's id: a target's Key view is open on key(<target>)"),
@@ -388,6 +388,8 @@ export const TOOLS = [
388
388
  id: str("the job's id"),
389
389
  what: str("pdf (default), tex, sty or zip", ["pdf", "tex", "sty", "zip"]),
390
390
  saveTo: SAVE_TO,
391
+ recipe: { type: "object", description: "the picture: size, orientation, style, theme, names (e.g. {clusters: 1}), targets, selection {name, mode}, title, subtitle, edges… (docs: export)" },
392
+ save: str("png, svg or png,svg: saved by the page once ready"),
391
393
  },
392
394
  },
393
395
  ];
@@ -14,7 +14,7 @@ import { pastGraph } from "../shared/pastgraph.js";
14
14
  import { pageGraph } from "./history.js";
15
15
  import { MOST_DIFF, snapshotDiff } from "./historyapi.js";
16
16
  /** how many past builds are kept: a past graph and the texts read for a large view are large on a
17
- * large project (the showcase's past graph: 13.7 MB of JSON) */
17
+ * large project (Sphere-Packing-Lean's past graph: 13.7 MB of JSON) */
18
18
  const KEEP = 2;
19
19
  export class PastBuilds {
20
20
  w;
@@ -13,6 +13,9 @@ import { buildViewOffThread } from "./view.js";
13
13
  import { cacheHome } from "./dirs.js";
14
14
  /** the tool's own folder, `src/`; the repository is above it */
15
15
  export const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
+ /** the fonts the page draws an artwork's names and title in (ExportSpec decision 19): Source Sans 3
17
+ * and Source Serif 4, their Latin and Greek, and Jost, its Latin, under the SIL Open Font License */
18
+ export const WEBFONTS = join(SRC, "assets", "webfonts");
16
19
  export const REPO = resolve(SRC, "..");
17
20
  export const WEB = join(SRC, "web");
18
21
  /** The page's bundle made when the package was built (scripts/build.mjs), beside the page: an
@@ -38,8 +41,9 @@ export function bundleDir() {
38
41
  * views from an older server are rebuilt rather than served. 7: a notation unit's statement is
39
42
  * its command's text. 8: notation units carry their Lean constant, display form, what they
40
43
  * interpret and their rules. 9: a node's hash is its version, beside the kernel's
41
- * (server/versions.ts). */
42
- const VIEW_VERSION = 9;
44
+ * (server/versions.ts). 10: the project's git source and commit (server/history.ts, gitSource). 11:
45
+ * not them any more: they are asked when wanted (GET /api/git), not kept with the view. */
46
+ const VIEW_VERSION = 11;
43
47
  function loadMemory(siteDir) {
44
48
  try {
45
49
  const mem = JSON.parse(readFileSync(join(siteDir, "layout-memory.json"), "utf8"));
@@ -60,7 +64,9 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
60
64
  // `reuse`: lay out an extraction already held, without reading the build (which may be
61
65
  // half-written right now); `extraction`: one in hand (the usage pass's, merged); `provisional`:
62
66
  // the graph waits for its usage pass; `completes`: the fingerprint of the build's extraction that
63
- // this graph, the usage pass's merged, completes
67
+ // this graph, the usage pass's merged, completes; `readOnlySite`: the site's layout memory read,
68
+ // never written, and the view laid out not kept in the cache either, which a server would take for
69
+ // its site's (the artwork command, ExportSpec decision 21)
64
70
  const held = opts.extraction ?? (opts.reuse ? cachedExtraction(dirs.cache, opts.reuse) : null);
65
71
  if (opts.reuse && !held)
66
72
  throw new Error("the graph on screen is no longer cached; it is laid out afresh at the next build");
@@ -69,7 +75,6 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
69
75
  log(ex.cached ? `extraction ${ex.fingerprint} reused` : `extraction ${ex.fingerprint} done`);
70
76
  const usage = ex.graph.usage ?? null;
71
77
  const viewDir = join(dirs.cache, "view");
72
- mkdirSync(viewDir, { recursive: true });
73
78
  const metaFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.meta.json.gz`);
74
79
  const edgeFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.edges.bin.gz`);
75
80
  const memory = opts.freshLayout ? null : loadMemory(dirs.site);
@@ -84,13 +89,17 @@ export async function prepare(project, dirs, saved, log, opts = {}) {
84
89
  const completing = memory !== null && opts.completes !== undefined && memory.fingerprint === opts.completes && memory.before !== undefined;
85
90
  const against = completing ? (memory.before ?? null) : (memory?.layouts ?? null);
86
91
  const view = await buildViewOffThread(project, ex, log, against);
87
- saveMemory(dirs.site, ex.fingerprint, view.meta, opts.provisional ? against : undefined);
92
+ if (!opts.readOnlySite)
93
+ saveMemory(dirs.site, ex.fingerprint, view.meta, opts.provisional ? against : undefined);
88
94
  const metaGz = gzipSync(JSON.stringify(view.meta));
89
95
  const edgesGz = gzipSync(Buffer.from(view.edges.buffer, view.edges.byteOffset, view.edges.byteLength));
90
- writeFileSync(metaFile, metaGz);
91
- writeFileSync(edgeFile, edgesGz);
92
- prune(viewDir, /\.meta\.json\.gz$/, 3);
93
- prune(viewDir, /\.edges\.bin\.gz$/, 3);
96
+ if (!opts.readOnlySite) {
97
+ mkdirSync(viewDir, { recursive: true });
98
+ writeFileSync(metaFile, metaGz);
99
+ writeFileSync(edgeFile, edgesGz);
100
+ prune(viewDir, /\.meta\.json\.gz$/, 3);
101
+ prune(viewDir, /\.edges\.bin\.gz$/, 3);
102
+ }
94
103
  return { fingerprint: ex.fingerprint, cached: ex.cached, metaGz, edgesGz, usage };
95
104
  }
96
105
  function readdirRec(dir) {
@@ -7,12 +7,12 @@ export function readOnlyAllows(method, path) {
7
7
  const read = method === "GET" || method === "HEAD";
8
8
  if (read) {
9
9
  // the page and its files
10
- if (path === "/" || path === "/index.html" || path === "/style.css" || /^\/(app|layout|edges|pdfworker)\.js(\.map)?$/.test(path) || path.startsWith("/pdfjs/"))
10
+ if (path === "/" || path === "/index.html" || path === "/style.css" || /^\/(app|layout|edges|pdfworker)\.js(\.map)?$/.test(path) || path.startsWith("/pdfjs/") || path.startsWith("/fonts/"))
11
11
  return true;
12
12
  // the graph, its sources and Lean's answers, the review, what happened, the views, the history,
13
13
  // the library, the printed document's state and files, who the reader is and the link they
14
14
  // came by, and the event stream every change comes by
15
- if (["/api/status", "/api/meta", "/api/edges", "/api/source", "/api/review", "/api/activity", "/api/identity", "/api/share"].includes(path))
15
+ if (["/api/status", "/api/meta", "/api/edges", "/api/git", "/api/source", "/api/review", "/api/activity", "/api/identity", "/api/share"].includes(path))
16
16
  return true;
17
17
  // a stream is opened by GET alone: a HEAD would take one of their places for nothing
18
18
  if (path === "/api/events")
@@ -263,7 +263,10 @@ export class ReviewStore {
263
263
  this.refresh();
264
264
  const file = logFileName(name, false, person);
265
265
  const now = Object.hasOwn(this.data.people, person) ? this.data.people[person].name : null;
266
- if (!this.files.has(file) || now === name.trim())
266
+ // one who has no log yet, but whom opinions were recorded for (ReviewSpec decision 8), is
267
+ // given one by the rename, so that those follow it
268
+ const spokenFor = Object.values(this.data.opinions).some((ops) => ops.some((o) => o.person === person && o.recordedBy));
269
+ if ((!this.files.has(file) && !spokenFor) || now === name.trim())
267
270
  return { change: null, unchanged: true };
268
271
  return this.write(file, () => nameEvent(person, name, this.nextTime()));
269
272
  }
@@ -17,6 +17,7 @@
17
17
  // Every refusal is an answer: a status, and words that say what to do instead.
18
18
  import { readFileSync } from "node:fs";
19
19
  import { join } from "node:path";
20
+ import { MAX_NAME, sameName, validName } from "../shared/collab.js";
20
21
  import { exactContext, judgedText } from "../shared/context.js";
21
22
  import { MAX_TEXT, STANDARD_AXIOMS, validPrefix } from "../shared/review.js";
22
23
  import { reviewSet } from "../shared/reviewqueue.js";
@@ -102,6 +103,9 @@ export class ReviewApi {
102
103
  return answer(refuse(400, "The comment is longer than 10,000 characters.", "Say it shorter."));
103
104
  if (b.verdict === "comment" && !comment)
104
105
  return answer(refuse(400, "A comment needs text.", "Write it in the box, then Comment."));
106
+ const voice = this.voice(b.for, who);
107
+ if ("status" in voice)
108
+ return answer(voice);
105
109
  const v = this.graph();
106
110
  if ("status" in v)
107
111
  return answer(v);
@@ -109,7 +113,7 @@ export class ReviewApi {
109
113
  if ("status" in x)
110
114
  return answer(x);
111
115
  // an agent's opinion is advice, whatever its level (AgentSpec decision 2)
112
- return this.record({ op: "opinion", name: x.name, by, verdict: b.verdict, ...(comment ? { comment } : {}), ...(x.hash ? { hash: x.hash } : {}), ...(x.text !== undefined ? { text: x.text } : x.statement !== undefined ? { statement: x.statement } : {}), ...agent }, who);
116
+ return this.record({ op: "opinion", name: x.name, by, verdict: b.verdict, ...(comment ? { comment } : {}), ...(x.hash ? { hash: x.hash } : {}), ...(x.text !== undefined ? { text: x.text } : x.statement !== undefined ? { statement: x.statement } : {}), ...voice, ...agent }, who);
113
117
  }
114
118
  case "target": {
115
119
  if (typeof b.on !== "boolean")
@@ -173,6 +177,36 @@ export class ReviewApi {
173
177
  return answer(refuse(400, "op is opinion, target, trust or key.", "Send {op, …}."));
174
178
  }
175
179
  }
180
+ /** Whose an opinion is to be (ReviewSpec decision 8): the asker's own where `asked` is absent or
181
+ * names them; else another person's, recorded for them — by their link's id where `asked` is an
182
+ * invited person's name, a guest's as the logs write it — and never an agent's, nor by an agent. */
183
+ voice(asked, who) {
184
+ if (asked === undefined)
185
+ return {};
186
+ if (who.agent)
187
+ return refuse(403, "An agent records no opinion for anyone else.", "Leave out for: your opinion is your own, and advice.");
188
+ if (typeof asked !== "string" || !validName(asked.trim()))
189
+ return refuse(400, `for is the name of the person the opinion is recorded for: 1–${MAX_NAME} characters, no line breaks.`, "Send {op: \"opinion\", name, verdict, for: \"<their name>\"}.");
190
+ const name = asked.trim();
191
+ const held = this.world.invited?.(name);
192
+ // one's own name, or one's own link: one's own opinion
193
+ if (held ? held.id === who.person : sameName(name, who.name.trim()))
194
+ return {};
195
+ // the writers the logs name, people's and agents', in any case
196
+ const d = this.world.review.data;
197
+ const writers = [
198
+ ...Object.values(d.opinions).flatMap((ops) => ops.map((o) => ({ by: o.by, agent: !!o.agent, person: !!o.person }))),
199
+ ...Object.values(d.targets).map((t) => ({ by: t.by, agent: !!t.agent, person: !!t.person })),
200
+ ...Object.values(d.keys).flatMap((marks) => Object.values(marks).map((m) => ({ by: m.by, agent: !!m.agent, person: !!m.person }))),
201
+ ];
202
+ if (writers.some((w) => w.agent && sameName(w.by, name)))
203
+ return refuse(400, `${name} is an agent: nobody records an opinion for an agent.`, "Record it for a person, or as your own.");
204
+ if (held)
205
+ return { for: held.name, forPerson: held.id };
206
+ // a guest already in the logs, as they write their name: "bob" is Bob
207
+ const guest = writers.find((w) => !w.agent && !w.person && sameName(w.by, name));
208
+ return { for: guest?.by ?? name };
209
+ }
176
210
  /** Append a writer's event to their log; the answer carries the change, which the page applies
177
211
  * at once, and the event as written. */
178
212
  record(op, who) {
@@ -3,12 +3,20 @@
3
3
  import { readFileSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { gunzipSync, gzipSync } from "node:zlib";
6
- import { WEB, bundleDir } from "./pipeline.js";
6
+ import { WEB, WEBFONTS, bundleDir } from "./pipeline.js";
7
+ import { gitSource } from "./history.js";
7
8
  import { lakeTextHash } from "./status.js";
8
9
  /** Text that sits inside a <script> element must not close it. */
9
10
  function scriptSafe(s) {
10
11
  return s.replace(/<\/script/gi, "<\\/script");
11
12
  }
13
+ /** A snapshot's graph: the view's, with the project's git source and commit as the snapshot is
14
+ * taken, since a snapshot has no server to ask (GET /api/git); as it was where the project is no
15
+ * git repository. */
16
+ export function withGit(root, meta, metaGz) {
17
+ const git = gitSource(root);
18
+ return git ? gzipSync(JSON.stringify({ ...meta, project: { ...meta.project, git } })) : metaGz;
19
+ }
12
20
  export function writeSnapshot(project, prep, out, label, review, views) {
13
21
  const meta = JSON.parse(gunzipSync(prep.metaGz).toString("utf8"));
14
22
  // the sources a card can show: every project module's file, with the usage pass's contexts where
@@ -28,13 +36,15 @@ export function writeSnapshot(project, prep, out, label, review, views) {
28
36
  }
29
37
  }
30
38
  const b64 = (buf) => buf.toString("base64");
31
- const css = readFileSync(join(WEB, "style.css"), "utf8");
39
+ const metaGz = withGit(project.root, meta, prep.metaGz);
40
+ // the artwork's fonts within the page, which has no server to ask for them
41
+ const css = readFileSync(join(WEB, "style.css"), "utf8").replace(/url\("\/fonts\/([a-z0-9-]+\.woff2)"\)/g, (_, f) => `url("data:font/woff2;base64,${b64(readFileSync(join(WEBFONTS, f)))}")`);
32
42
  const js = readFileSync(join(bundleDir(), "app.js"), "utf8").replace(/\n\/\/# sourceMappingURL=.*$/m, "");
33
43
  const info = { createdAt: new Date().toISOString(), label };
34
44
  const html = readFileSync(join(WEB, "index.html"), "utf8")
35
45
  .replace(`<link rel="stylesheet" href="/style.css">`, () => `<style>\n${css}\n</style>`)
36
46
  .replace(`<script type="module" src="/app.js"></script>`, () => `<script id="vp-static" type="application/json">${scriptSafe(JSON.stringify(info))}</script>\n` +
37
- `<script id="vp-meta" type="application/octet-stream">${b64(prep.metaGz)}</script>\n` +
47
+ `<script id="vp-meta" type="application/octet-stream">${b64(metaGz)}</script>\n` +
38
48
  `<script id="vp-edges" type="application/octet-stream">${b64(prep.edgesGz)}</script>\n` +
39
49
  `<script id="vp-sources" type="application/octet-stream">${b64(gzipSync(JSON.stringify(sources)))}</script>\n` +
40
50
  // the review as it stood: shown, not changed (ReviewSpec decision 7)
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // One process per module: a process cannot import twice. `jobs` run at once; import is bound by
13
13
  // memory bandwidth, and each worker holds hundreds of MB of its own besides the `.olean` files they
14
- // all map (on the showcase about 150 MB of its own, 1.2 to 1.6 GB resident with them). A module's result is cached in the
14
+ // all map (on Sphere-Packing-Lean about 150 MB of its own, 1.2 to 1.6 GB resident with them). A module's result is cached in the
15
15
  // project's cache directory under a key
16
16
  // made of its `.olean` hash, the text it was compiled from, the options Lake compiled it with, and
17
17
  // the keys of the project modules it imports: after a build only the modules whose key changed run
@@ -25,7 +25,7 @@ import { SITE_BODY, SITE_GRAMMAR, SITE_PROOF, SITE_TCB } from "../shared/graph.j
25
25
  import { USAGE, USAGE_PROGRAM, leanProgram, prune } from "./extract.js";
26
26
  import { compiledTextHash, lakeTextHash } from "./status.js";
27
27
  /** How many workers run at once unless `--jobs` says otherwise: past about a quarter of the cores,
28
- * more workers gain less and less (the showcase's cold pass on 32 cores: 320 s with 4, 195 s with
28
+ * more workers gain less and less (Sphere-Packing-Lean's cold pass on 32 cores: 320 s with 4, 195 s with
29
29
  * 8, 148 s with 16). */
30
30
  export const DEFAULT_JOBS = 8;
31
31
  /** A worker still running after this long is stopped, and its module reported as failed. */
@@ -59,7 +59,7 @@ const HOLD_MS = 60_000;
59
59
  /** the most views one private page holds */
60
60
  const MAX_HOLDS = 100;
61
61
  /** The most a board's layout memory may be when a page hands it over: room for a board of a large
62
- * project's declarations many times over (the showcase's whole board, 7,851 declarations, is
62
+ * project's declarations many times over (Sphere-Packing-Lean's whole board, 7,851 declarations, is
63
63
  * 1.8 MB; 50 MB is the owner's decision of 2026-10-14). */
64
64
  export const MAX_MEMORY_BYTES = 50 << 20;
65
65
  const isPins = (p) => {
@@ -161,6 +161,30 @@ function cleanMemory(x) {
161
161
  return null;
162
162
  return { set: m.set, mem: { nodes, clusters } };
163
163
  }
164
+ /** The Project view as a site keeps it — its pins, folds, hidden levels and clustering — read and
165
+ * nothing written (the artwork command, ExportSpec decision 21; the Views class sets aside a file
166
+ * it cannot read): null where the site keeps none, or `why` where its views cannot be read. Pins
167
+ * dropped under another way of laying out mean nothing in this one, as Views reads them. */
168
+ export function readProjectView(siteDir, layoutVersion) {
169
+ const file = join(siteDir, "views.json");
170
+ if (!existsSync(file))
171
+ return { view: null };
172
+ try {
173
+ const raw = JSON.parse(readFileSync(file, "utf8"));
174
+ for (const r of Array.isArray(raw.views) ? raw.views : []) {
175
+ const v = readView(r);
176
+ if (typeof v === "string" || v.id !== PROJECT)
177
+ continue;
178
+ if (raw.layoutVersion !== layoutVersion)
179
+ v.pins = emptyLayouts();
180
+ return { view: v };
181
+ }
182
+ return { view: null };
183
+ }
184
+ catch (e) {
185
+ return { view: null, why: e instanceof Error ? e.message : String(e) };
186
+ }
187
+ }
164
188
  export class Views {
165
189
  book;
166
190
  file;